Files
hyperframes/packages/studio/src/contexts/DomEditContext.tsx
T
Miguel Ángel 4cc46f5f9f feat(studio): edit and style text in the preview (#3143)
* 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
2026-08-11 03:54:18 -04:00

400 lines
12 KiB
TypeScript

// fallow-ignore-file code-duplication
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import type { useDomEditSession } from "../hooks/useDomEditSession";
type DomEditValue = ReturnType<typeof useDomEditSession>;
export interface DomEditActionsValue extends Pick<
DomEditValue,
| "handleTimelineElementSelect"
| "handlePreviewCanvasMouseDown"
| "handlePreviewCanvasPointerMove"
| "handlePreviewCanvasPointerLeave"
| "applyDomSelection"
| "clearDomSelection"
| "handleDomStyleCommit"
| "handleDomAttributeCommit"
| "handleDomAttributeLiveCommit"
| "handleDomHtmlAttributeCommit"
| "handleDomAttributesCommit"
| "handleDomPathOffsetCommit"
| "handleDomGroupPathOffsetCommit"
| "handleDomZIndexReorderCommit"
| "handleDomBoxSizeCommit"
| "handleDomRotationCommit"
| "handleDomManualEditsReset"
| "handleDomTextCommit"
| "handleDomRichTextCommit"
| "handleDomTextFieldStyleCommit"
| "handleDomAddTextField"
| "handleDomRemoveTextField"
| "handleAskAgent"
| "handleAgentModalSubmit"
| "handleBlockedDomMove"
| "handleDomManualDragStart"
| "handleDomEditElementDelete"
| "handleGroupSelection"
| "handleUngroupSelection"
| "setActiveGroupElement"
| "buildDomSelectionFromTarget"
| "buildDomSelectionForTimelineElement"
| "updateDomEditHoverSelection"
| "resolveImportedFontAsset"
| "setAgentModalOpen"
| "setAgentPromptSelectionContext"
| "setAgentModalAnchorPoint"
| "handleGsapUpdateProperty"
| "handleGsapUpdateMeta"
| "handleGsapDeleteAnimation"
| "handleGsapDeleteAllForElement"
| "handleGsapAddAnimation"
| "handleGsapAddProperty"
| "handleGsapRemoveProperty"
| "handleGsapUpdateFromProperty"
| "handleGsapAddFromProperty"
| "handleGsapRemoveFromProperty"
| "handleGsapAddKeyframe"
| "handleGsapAddKeyframeBatch"
| "handleGsapRemoveKeyframe"
| "handleGsapMoveKeyframeToPlayhead"
| "handleGsapMoveKeyframe"
| "handleGsapResizeKeyframedTween"
| "handleGsapConvertToKeyframes"
| "handleGsapRemoveAllKeyframes"
| "handleResetSelectedElementKeyframes"
| "commitAnimatedProperty"
| "commitAnimatedProperties"
| "handleSetArcPath"
| "handleUpdateArcSegment"
| "handleUnroll"
| "invalidateGsapCache"
| "previewIframeRef"
| "commitMutation"
| "applyMarqueeSelection"
| "handleUpdateKeyframeEase"
| "handleUpdateSegmentEase"
| "handleSetAllKeyframeEases"
> {}
export interface DomEditSelectionValue extends Pick<
DomEditValue,
| "domEditSelection"
| "domEditGroupSelections"
| "domEditHoverSelection"
| "activeGroupElement"
| "domEditSelectionRef"
| "selectedGsapAnimations"
| "gsapMultipleTimelines"
| "gsapUnsupportedTimelinePattern"
| "agentModalOpen"
| "agentModalAnchorPoint"
| "copiedAgentPrompt"
| "agentPromptSelectionContext"
> {}
const DomEditActionsContext = createContext<DomEditActionsValue | null>(null);
const DomEditSelectionContext = createContext<DomEditSelectionValue | null>(null);
export function useDomEditActionsContext(): DomEditActionsValue {
const ctx = useContext(DomEditActionsContext);
if (!ctx) throw new Error("useDomEditActionsContext must be used within DomEditProvider");
return ctx;
}
/**
* Optional access — returns null outside a provider. Lets the player-package
* <Timeline> (a public standalone export) reach the z-order persist path when
* embedded in the NLE without hard-requiring the provider in standalone/test mounts.
*/
export function useDomEditActionsContextOptional(): DomEditActionsValue | null {
return useContext(DomEditActionsContext);
}
export function useDomEditSelectionContext(): DomEditSelectionValue {
const ctx = useContext(DomEditSelectionContext);
if (!ctx) throw new Error("useDomEditSelectionContext must be used within DomEditProvider");
return ctx;
}
/** @deprecated Prefer useDomEditActionsContext or useDomEditSelectionContext. */
export function useDomEditContext(): DomEditValue {
return { ...useDomEditActionsContext(), ...useDomEditSelectionContext() };
}
export function DomEditProvider({
value: {
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
agentModalOpen,
agentModalAnchorPoint,
copiedAgentPrompt,
agentPromptSelectionContext,
domEditSelectionRef,
handleTimelineElementSelect,
handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
handleDomTextCommit,
handleDomRichTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleAskAgent,
handleAgentModalSubmit,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
handleGroupSelection,
handleUngroupSelection,
setActiveGroupElement,
activeGroupElement,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
resolveImportedFontAsset,
setAgentModalOpen,
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapMoveKeyframeToPlayhead,
handleGsapMoveKeyframe,
handleGsapResizeKeyframedTween,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
commitAnimatedProperties,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache,
previewIframeRef,
commitMutation,
applyMarqueeSelection,
handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases,
},
children,
}: {
value: DomEditValue;
children: ReactNode;
}) {
const commitMutationRef = useRef(commitMutation);
commitMutationRef.current = commitMutation;
const stableCommitMutation = useCallback<DomEditActionsValue["commitMutation"]>(
(mutation, options) => commitMutationRef.current(mutation, options),
[],
);
const actions = useMemo<DomEditActionsValue>(
() => ({
handleTimelineElementSelect,
handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
handleDomTextCommit,
handleDomRichTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleAskAgent,
handleAgentModalSubmit,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
handleGroupSelection,
handleUngroupSelection,
setActiveGroupElement,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
resolveImportedFontAsset,
setAgentModalOpen,
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapMoveKeyframeToPlayhead,
handleGsapMoveKeyframe,
handleGsapResizeKeyframedTween,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
commitAnimatedProperties,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache,
previewIframeRef,
commitMutation: stableCommitMutation,
applyMarqueeSelection,
handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases,
}),
[
handleTimelineElementSelect,
handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
handleDomTextCommit,
handleDomRichTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleAskAgent,
handleAgentModalSubmit,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
handleGroupSelection,
handleUngroupSelection,
setActiveGroupElement,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
resolveImportedFontAsset,
setAgentModalOpen,
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapMoveKeyframeToPlayhead,
handleGsapMoveKeyframe,
handleGsapResizeKeyframedTween,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
commitAnimatedProperties,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache,
previewIframeRef,
stableCommitMutation,
applyMarqueeSelection,
handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases,
],
);
const selection = useMemo<DomEditSelectionValue>(
() => ({
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
domEditSelectionRef,
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
agentModalOpen,
agentModalAnchorPoint,
copiedAgentPrompt,
agentPromptSelectionContext,
}),
[
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
domEditSelectionRef,
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
agentModalOpen,
agentModalAnchorPoint,
copiedAgentPrompt,
agentPromptSelectionContext,
],
);
return (
<DomEditActionsContext value={actions}>
<DomEditSelectionContext value={selection}>{children}</DomEditSelectionContext>
</DomEditActionsContext>
);
}