import { useRef, type ReactNode, type RefObject } from "react"; import { InlineTextToolbar } from "./InlineTextToolbar"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; import { useInlineTextEdit } from "../../hooks/useInlineTextEdit"; import { usePlayerStore } from "../../player/store/playerStore"; import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; import { canEditElementTextInline, canEditTextInline, isDoublePress, type PressMark, } from "./domEditInlineText"; import type { DomEditSelection } from "./domEditingTypes"; /** * The canvas' side of editing text where it sits. * * Holds the session, decides which presses open one, and knows the two things * the canvas has to do differently while one is open: stand aside so the caret * underneath can be reached, and stop taking focus back. * * The actions context is read here rather than threaded through the overlay's * props, the same way the agent surfaces in that overlay read it, and it is * absent in standalone player mounts, which have no project to edit. */ export function useInlineTextEditing(selectionRef: RefObject): { editing: boolean; /** Open an edit when this press pairs with the last one. */ startFromPress: (event: { clientX: number; clientY: number }) => boolean; /** Handle a key on the canvas. Returns true when it opened an edit. */ handleKeyDown: (event: { key: string; shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; altKey: boolean; }) => boolean; /** * The styling controls for the current selection, for the caller to render. * * Handed back rather than mounted somewhere central because it belongs to the * session this hook owns, and appears and disappears with it. */ toolbar: ReactNode; } { const actions = useDomEditActionsContextOptional(); const inlineText = useInlineTextEdit({ onCommit: (commit) => void actions?.handleDomRichTextCommit(commit), onPause: () => usePlayerStore.getState().setIsPlaying(false), }); const lastPressRef = useRef(null); /** * The element under this press, hit-tested now rather than read from state. * * Every cached answer in the overlay is React state that has not caught up * with the press happening now: the hover is documented as an async cache, * and the selection from the first press has not re-rendered. Reading either * meant the first double press on any element opened nothing, which is every * double press that matters. */ const elementUnderPress = (event: { clientX: number; clientY: number }) => { const iframe = actions?.previewIframeRef?.current; if (!iframe) return null; // Studio suppresses pointer events inside the composition so the canvas // overlay can own input, which means a plain elementFromPoint only ever // finds wrappers. This helper lifts that for the length of the hit test, // and is the same one the canvas uses to decide what was clicked. return getPreviewTargetFromPointer( iframe, event.clientX, event.clientY, selectionRef.current?.compositionPath ?? null, ); }; /** A point on Studio's canvas, in the scaled composition's coordinates. */ const compositionPoint = (event: { clientX: number; clientY: number }) => { const iframe = actions?.previewIframeRef?.current; const view = iframe?.contentWindow; if (!iframe || !view?.innerWidth) return undefined; const box = iframe.getBoundingClientRect(); const scale = box.width / view.innerWidth || 1; return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale }; }; return { editing: inlineText.session !== null, toolbar: ( ), // Enter opens the selected element's text, the way every design tool does, // and is the dependable way in: a double press has to survive the canvas' // gesture machinery, while this is one key on a selection that has settled. handleKeyDown: (event) => { if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return false; const target = selectionRef.current; if (inlineText.session || !target || !canEditTextInline(target)) return false; return inlineText.start(target.element); }, startFromPress: (event) => { if (inlineText.session) return false; const element = elementUnderPress(event); const press = { x: event.clientX, y: event.clientY, at: Date.now(), element }; const paired = isDoublePress(lastPressRef.current, press); lastPressRef.current = press; if (!paired) return false; if (!element || !canEditElementTextInline(element)) return false; // The caret opens where the press landed, which means mapping the point // out of Studio's coordinates and into the composition's own. return inlineText.start(element, compositionPoint(event)); }, }; }