import { Point, Rect } from "@noya-app/noya-geometry"; import { getSelectionRangeForUnit, transformBeforeInput, transformKeyDown, VMeasuredCharacter, VRange, } from "@noya-app/text-editing"; import React from "react"; import { offsetRangeToSelection, selectionToOffsetRange, } from "../utils/textSelection"; import { createCanvasPlugin } from "./baseCanvas"; import { BaseContextProperties, ElementProperties, HandleKeyboardEventProperties, ScreenPointProperties, TextEditableProperties, } from "./contextProperties"; import { modeSwitch, NoneMode } from "./PluginSystem"; import { PointerData } from "./pointerPlugin"; export type { SelectionPoint, SelectionRange } from "../utils/textSelection"; export type EditingTextMode = { editingElementId: string; range: VRange | null; type: "editingText"; }; export type EditTextMode = NoneMode | EditingTextMode; export type EditTextData = { zoom: number; selectedElementIds?: string[]; textDragAnchor?: number | null; isDraggingText?: boolean; textSelectionUnit?: "character" | "word" | "paragraph"; textInitialSelection?: { anchor: number; focus: number } | null; } & PointerData; export type TextEditElement = { id: string; rect: Rect; text: string; }; export type TextMeasurement = { measuredCharacters: VMeasuredCharacter[]; }; interface EditTextContextProperties extends BaseContextProperties, HandleKeyboardEventProperties, ScreenPointProperties, ElementProperties, TextEditableProperties { getTextElement: (elementId: string) => TextEditElement | undefined; updateText: (elementId: string, nextText: string) => void; measureText?: (element: TextEditElement) => TextMeasurement | undefined; hitTestText?: ( element: TextEditElement, parameters: { canvasPoint: Point; localPoint: Point } ) => number | undefined; getSelectedTextElementId?: () => string | undefined; onTextPointerDown?: (event: React.PointerEvent, elementId: string) => void; onTextPointerMove?: (event: React.PointerEvent, elementId: string) => void; onTextPointerUp?: (event: React.PointerEvent, elementId: string) => void; onBlur?: (elementId: string) => void; } export type EditTextPluginOptions = { triggerEvent?: "click" | "doubleClick"; }; export function editTextPlugin( options: EditTextPluginOptions = {} ) { const { triggerEvent = "doubleClick" } = options; return createCanvasPlugin({ name: "editText", data: { zoom: 1, clickCount: 0, lastClickTime: 0, textDragAnchor: null, isDraggingText: false, }, handlers: (context: EditTextContextProperties) => { const getTextElement = (elementId: string) => context.getTextElement(elementId); const isTriggerClick = (clickCount: number | undefined) => triggerEvent === "doubleClick" ? (clickCount ?? 0) >= 2 : (clickCount ?? 0) >= 1; const getMovementUnit = (clickCount: number | undefined) => { if (!clickCount || clickCount <= 1) return "character"; return clickCount % 2 === 0 ? "word" : "paragraph"; }; const setSelectionRange = (nextRange: { anchor: number; focus: number; xPosition?: number; }) => { context.setMode((state) => { if (state.type !== "editingText") return state; return { ...state, range: offsetRangeToSelection(nextRange), }; }); }; const updateTextAndSelection = (params: { elementId: string; nextText: string; nextRange: { anchor: number; focus: number; xPosition?: number }; }) => { context.updateText(params.elementId, params.nextText); setSelectionRange(params.nextRange); }; const getSelectedTextElementId = () => { const selectedFromContext = context.getSelectedTextElementId?.(); if (selectedFromContext) return selectedFromContext; const data = context.getData(); const selectedIds = data.selectedElementIds ?? []; if (selectedIds.length === 1) return selectedIds[0]; return undefined; }; const getEditingState = () => { const mode = context.getMode(); if (mode.type !== "editingText") return undefined; const element = getTextElement(mode.editingElementId); if (!element) return undefined; const range = selectionToOffsetRange( mode.range ?? null, element.text.length ) ?? { anchor: element.text.length, focus: element.text.length, xPosition: mode.range?.xPosition, }; return { element, range }; }; const getEditingStateOrSelectedText = () => { const existing = getEditingState(); if (existing) return existing; const selectedId = getSelectedTextElementId(); if (!selectedId) return undefined; const element = getTextElement(selectedId); if (!element) return undefined; const range = { anchor: element.text.length, focus: element.text.length, xPosition: undefined, }; const selection = offsetRangeToSelection(range); context.setMode({ type: "editingText", editingElementId: element.id, range: selection, }); context.setShouldHandleKeyboardEvents(false); return { element, range }; }; const measureText = (element: TextEditElement) => context.measureText?.(element); const getOffsetFromEvent = ( elementId: string, event: React.PointerEvent ) => { const element = getTextElement(elementId); if (!element) return undefined; const canvasPoint = context.getCanvasPoint(event.nativeEvent); const localPoint = { x: canvasPoint.x - element.rect.x, y: canvasPoint.y - element.rect.y, }; const offset = context.hitTestText?.(element, { canvasPoint, localPoint }) ?? element.text.length; return { offset, element }; }; const handleTextMouseDown = ( elementId: string, event: React.PointerEvent ) => { const result = getOffsetFromEvent(elementId, event); if (!result) return; const data = context.getData(); const movementUnit = getMovementUnit(data.clickCount); const measuredCharacters = measureText(result.element)?.measuredCharacters ?? []; const offsetRange = getSelectionRangeForUnit({ measuredCharacters, offset: result.offset, unit: movementUnit, textLength: result.element.text.length, }); context.setData((current) => ({ ...current, textDragAnchor: result.offset, isDraggingText: true, textSelectionUnit: movementUnit, textInitialSelection: offsetRange, })); setSelectionRange(offsetRange); }; const handleTextMouseMove = ( elementId: string, event: React.PointerEvent ) => { const data = context.getData(); const dragAnchor = data.textDragAnchor ?? null; if (!data.isDraggingText || dragAnchor === null) return; const result = getOffsetFromEvent(elementId, event); if (!result) return; const movementUnit = data.textSelectionUnit ?? "character"; const measuredCharacters = measureText(result.element)?.measuredCharacters ?? []; const currentRange = getSelectionRangeForUnit({ measuredCharacters, offset: result.offset, unit: movementUnit, textLength: result.element.text.length, }); let offsetRange: { anchor: number; focus: number }; if (movementUnit === "character") { offsetRange = { anchor: dragAnchor, focus: result.offset, }; } else { const initial = data.textInitialSelection ?? { anchor: dragAnchor, focus: dragAnchor, }; const min = Math.min( initial.anchor, initial.focus, currentRange.anchor, currentRange.focus ); const max = Math.max( initial.anchor, initial.focus, currentRange.anchor, currentRange.focus ); offsetRange = { anchor: min, focus: max }; } setSelectionRange(offsetRange); }; const handleTextMouseUp = () => { context.setData((current) => ({ ...current, isDraggingText: false, textDragAnchor: null, textSelectionUnit: undefined, textInitialSelection: null, })); }; const handleTextBlur = () => { context.setData((current) => ({ ...current, isDraggingText: false, textDragAnchor: null, textSelectionUnit: undefined, textInitialSelection: null, })); }; return { onPointerDown(event) { modeSwitch(context.getMode(), { none: () => { const data = context.getData(); if (!isTriggerClick(data.clickCount)) return; const point = context.getCanvasPoint(event.nativeEvent); const elements = context.filterElements({ at: point, predicate: context.isTextEditable, }); if (elements.length === 0) return; const elementId = context.getElementId(elements[0]); context.setMode({ type: "editingText", editingElementId: elementId, range: null, }); context.setShouldHandleKeyboardEvents(false); event.preventDefault(); handleTextMouseDown(elementId, event); context.onTextPointerDown?.(event, elementId); }, editingText: (state) => { const data = context.getData(); const point = context.getCanvasPoint(event.nativeEvent); const elements = context.filterElements({ at: point, predicate: context.isTextEditable, }); if (elements.length === 0) { context.setMode({ type: "none", }); context.onBlur?.(state.editingElementId); handleTextBlur(); context.setShouldHandleKeyboardEvents(true); return; } const elementId = context.getElementId(elements[0]); if (elementId !== state.editingElementId) { if ( triggerEvent === "click" && isTriggerClick(data.clickCount) ) { context.onBlur?.(state.editingElementId); handleTextBlur(); context.setMode({ type: "editingText", editingElementId: elementId, range: null, }); context.setShouldHandleKeyboardEvents(false); event.preventDefault(); handleTextMouseDown(elementId, event); context.onTextPointerDown?.(event, elementId); return; } context.setMode({ type: "none", }); context.onBlur?.(state.editingElementId); handleTextBlur(); context.setShouldHandleKeyboardEvents(true); return; } event.preventDefault(); handleTextMouseDown(state.editingElementId, event); context.onTextPointerDown?.(event, state.editingElementId); }, }); }, onPointerMove(event) { modeSwitch(context.getMode(), { editingText: (state) => { event.preventDefault(); handleTextMouseMove(state.editingElementId, event); context.onTextPointerMove?.(event, state.editingElementId); }, }); }, onPointerUp(event) { modeSwitch(context.getMode(), { editingText: (state) => { event.preventDefault(); handleTextMouseUp(); context.onTextPointerUp?.(event, state.editingElementId); return; }, }); }, onBeforeInput(event) { modeSwitch(context.getMode(), { editingText: () => { const editingState = getEditingStateOrSelectedText(); if (!editingState) return; const { element, range } = editingState; const measuredCharacters = measureText(element)?.measuredCharacters ?? []; event.preventDefault(); const result = transformBeforeInput({ event, text: element.text, range, measuredCharacters, }); if (!result) return; updateTextAndSelection({ elementId: element.id, nextText: result.nextText, nextRange: result.nextRange, }); }, }); }, onKeyDown(event) { modeSwitch(context.getMode(), { editingText: () => { const editingState = getEditingStateOrSelectedText(); if (!editingState) return; const { element, range } = editingState; const measuredCharacters = measureText(element)?.measuredCharacters ?? []; const nextRange = transformKeyDown({ event, measuredCharacters, range, }); if (nextRange) { setSelectionRange(nextRange); event.preventDefault(); } }, }); }, }; }, }); }