mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
* feat(studio): edit and style text in the preview Double-press a text element in the canvas and the caret opens where you pressed, in the element itself rather than in a panel. Select characters and a small toolbar offers colour, bold, italic and underline, applied to exactly those characters. The toolbar lives in Studio's document rather than the composition's. Putting it in the preview would inject Studio's chrome into the user's composition, where a render would capture it and the composition's own styling would inherit into it. In a flex or grid container the rebuilt runs go inside one wrapper, so a coloured word cannot reflow the element it sits in. Also fixes the keyboard: the shortcut guards matched contenteditable=true only, so playback shortcuts ate letters typed into the composition. * refactor(studio): keep domEditingLayers under the size cap The rich-text operation pushed this file past the 600-line gate. Same change the branch made later, landed with the commit that caused it. * test(studio): wrap selection changes in act * fix(studio): restore rich text after failed save * fix(studio): polish inline text editing * fix(studio): harden inline text editing
120 lines
5.0 KiB
TypeScript
120 lines
5.0 KiB
TypeScript
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<DomEditSelection | null>): {
|
|
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<PressMark | null>(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: (
|
|
<InlineTextToolbar
|
|
session={inlineText.session}
|
|
iframe={actions?.previewIframeRef?.current ?? null}
|
|
/>
|
|
),
|
|
// 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));
|
|
},
|
|
};
|
|
}
|