From 9c94496f92e2b5df8efaa07c06e7271420c87bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 15:49:25 -0400 Subject: [PATCH 01/26] fix: stabilize studio preview and runtime sync --- packages/studio/src/player/hooks/useTimelinePlayer.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 470c2da8b..d534d3859 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -244,7 +244,6 @@ function buildTimelineElementKey(params: { if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`; return `${scope}:${params.id}:${params.fallbackIndex}`; } - function findTimelineDomNode(doc: Document, id: string): Element | null { return ( doc.getElementById(id) ?? @@ -290,7 +289,6 @@ export function buildStandaloneRootTimelineElement(params: { sourceFile: compositionSrc, }; } - function normalizePreviewViewport(doc: Document, win: Window): void { if (doc.documentElement) { doc.documentElement.style.overflow = "hidden"; From ae8297dc67b579c89c85c5ae5efa1c0fe9a4a0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 17:23:56 -0400 Subject: [PATCH 02/26] fix: pass selector through timeline thumbnails --- packages/studio/src/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 7bd4d9854..e18b87f10 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -377,6 +377,7 @@ export function StudioApp() { selector={el.selector} seekTime={0} duration={el.duration} + selector={el.selector} /> ); } @@ -393,6 +394,7 @@ export function StudioApp() { selector={el.selector} seekTime={el.start} duration={el.duration} + selector={el.selector} /> ); } @@ -439,6 +441,7 @@ export function StudioApp() { selector={el.selector} seekTime={el.start} duration={el.duration} + selector={el.selector} /> ); } From 8db28f89289a604614b3fa02003859b28e5254fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 15:50:13 -0400 Subject: [PATCH 03/26] feat: add studio timeline editing --- packages/studio/src/App.tsx | 5 +---- packages/studio/src/player/components/Timeline.tsx | 2 +- .../studio/src/player/components/timelineEditing.test.ts | 2 -- packages/studio/src/player/components/timelineEditing.ts | 2 -- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index e18b87f10..4e19e5272 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -344,7 +344,7 @@ export function StudioApp() { () => () => { if (previewHotkeyWindowRef.current) { previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey); - previewHotkeyWindowRef.current = null; + previewHotkeyWindowRef.current = null; } }, [handleTimelineToggleHotkey], @@ -374,7 +374,6 @@ export function StudioApp() { label={el.id || el.tag} labelColor={style.label} accentColor={style.clip} - selector={el.selector} seekTime={0} duration={el.duration} selector={el.selector} @@ -391,7 +390,6 @@ export function StudioApp() { label={el.id || el.tag} labelColor={style.label} accentColor={style.clip} - selector={el.selector} seekTime={el.start} duration={el.duration} selector={el.selector} @@ -438,7 +436,6 @@ export function StudioApp() { label={el.id || el.tag} labelColor={style.label} accentColor={style.clip} - selector={el.selector} seekTime={el.start} duration={el.duration} selector={el.selector} diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 7dba1e138..8c471b3ed 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1146,7 +1146,7 @@ export const Timeline = memo(function Timeline({ background: `${clipStyle.accent}26`, boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`, }} - > + > {element.tag} diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index 2ca13fe59..4ff03701b 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -428,7 +428,6 @@ describe("buildClipRangeSelection", () => { }); }); }); - describe("resolveTimelineAutoScroll", () => { it("does not scroll when the pointer stays away from the edges", () => { expect( @@ -512,7 +511,6 @@ describe("buildTimelineElementAgentPrompt", () => { ).toContain("If this clip is animated with GSAP"); }); }); - describe("resolveTimelineResize", () => { it("shrinks clip duration from the right edge", () => { expect( diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 9f8ffc8ae..21cd6013a 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -273,7 +273,6 @@ export function buildClipRangeSelection( anchorY: anchor.anchorY, }; } - export function buildTimelineAgentPrompt({ rangeStart, rangeEnd, @@ -347,7 +346,6 @@ export function buildTimelineElementAgentPrompt(element: { return lines.join("\n"); } - export function formatTimelineAttributeNumber(value: number): string { return Number(roundToCentiseconds(value).toFixed(2)).toString(); } From 02f1871c1c39ffcfd32aad9776058d745d094dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 18:19:46 -0400 Subject: [PATCH 04/26] fix: disambiguate timeline edit targets --- packages/studio/src/App.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4e19e5272..b11386359 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -376,7 +376,6 @@ export function StudioApp() { accentColor={style.clip} seekTime={0} duration={el.duration} - selector={el.selector} /> ); } @@ -392,7 +391,6 @@ export function StudioApp() { accentColor={style.clip} seekTime={el.start} duration={el.duration} - selector={el.selector} /> ); } @@ -438,7 +436,6 @@ export function StudioApp() { accentColor={style.clip} seekTime={el.start} duration={el.duration} - selector={el.selector} /> ); } From 82dc73eeb1769354dd1294ef63d1f215b9a293ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 16:49:40 -0400 Subject: [PATCH 05/26] fix: stop timeline auto-scroll in fit mode --- packages/studio/src/player/components/Timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 8c471b3ed..6303a390d 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -204,7 +204,6 @@ export function resolveTimelineAssetDrop( track: getDefaultDroppedTrack(input.trackOrder, rowIndex), }; } - /* ── Component ──────────────────────────────────────────────────── */ interface TimelineProps { /** Called when user seeks via ruler/track click or playhead drag */ @@ -478,6 +477,7 @@ export const Timeline = memo(function Timeline({ !isDragging.current && shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth) ) { + const playheadX = GUTTER + px; const visibleRight = scroll.scrollLeft + scroll.clientWidth; const visibleLeft = scroll.scrollLeft; const edgeMargin = scroll.clientWidth * 0.12; From ca495dfcc9a703a13c55c192404082a652970d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 18:00:51 -0400 Subject: [PATCH 06/26] feat: use percentage-based timeline zoom --- packages/studio/src/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b11386359..605f82731 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -37,12 +37,15 @@ import { getNextTimelineZoomPercent, getTimelineZoomPercent, } from "./player/components/timelineZoom"; +<<<<<<< HEAD import { getTimelineEditorHintDismissed, getTimelineToggleTitle, setTimelineEditorHintDismissed, shouldHandleTimelineToggleHotkey, } from "./utils/timelineDiscovery"; +======= +>>>>>>> b52f8e16 (feat: use percentage-based timeline zoom) interface EditingFile { path: string; @@ -344,7 +347,7 @@ export function StudioApp() { () => () => { if (previewHotkeyWindowRef.current) { previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey); - previewHotkeyWindowRef.current = null; + previewHotkeyWindowRef.current = null; } }, [handleTimelineToggleHotkey], From 87ac79f1092f616ec1884583f0d127cd360a2453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 18:06:54 -0400 Subject: [PATCH 07/26] fix: sync timeline playhead on zoom changes --- packages/studio/src/player/components/Timeline.test.ts | 5 ++++- packages/studio/src/player/components/Timeline.tsx | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 3151f4a0b..99f23957d 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +<<<<<<< HEAD import { generateTicks, getDefaultDroppedTrack, @@ -9,6 +10,9 @@ import { shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, } from "./Timeline"; +======= +import { generateTicks, getTimelinePlayheadLeft, shouldAutoScrollTimeline } from "./Timeline"; +>>>>>>> e00a18b1 (fix: sync timeline playhead on zoom changes) import { formatTime } from "../lib/time"; describe("generateTicks", () => { @@ -144,7 +148,6 @@ describe("getTimelineScrollLeftForZoomTransition", () => { expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480); }); }); - describe("getTimelinePlayheadLeft", () => { it("converts time to a pixel offset from the gutter", () => { expect(getTimelinePlayheadLeft(4, 20)).toBe(112); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 6303a390d..9b89ba4cc 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -131,7 +131,6 @@ export function getTimelineScrollLeftForZoomTransition( if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0; return currentScrollLeft; } - export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number { if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER; return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond); @@ -462,7 +461,6 @@ export const Timeline = memo(function Timeline({ ); previousZoomModeRef.current = zoomMode; }, [zoomMode]); - useMountEffect(() => { const unsub = liveTime.subscribe((t) => { const dur = durationRef.current; @@ -477,7 +475,6 @@ export const Timeline = memo(function Timeline({ !isDragging.current && shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth) ) { - const playheadX = GUTTER + px; const visibleRight = scroll.scrollLeft + scroll.clientWidth; const visibleLeft = scroll.scrollLeft; const edgeMargin = scroll.clientWidth * 0.12; From 6521b57cd15a7cec592082a0d76201ef889bd4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 18:44:26 -0400 Subject: [PATCH 08/26] fix: reset timeline scroll when returning to fit --- packages/studio/src/player/components/Timeline.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 99f23957d..4d476be13 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 612b92ae (fix: reset timeline scroll when returning to fit) import { generateTicks, getDefaultDroppedTrack, @@ -10,9 +13,12 @@ import { shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, } from "./Timeline"; +<<<<<<< HEAD ======= import { generateTicks, getTimelinePlayheadLeft, shouldAutoScrollTimeline } from "./Timeline"; >>>>>>> e00a18b1 (fix: sync timeline playhead on zoom changes) +======= +>>>>>>> 612b92ae (fix: reset timeline scroll when returning to fit) import { formatTime } from "../lib/time"; describe("generateTicks", () => { From d64045fc0a47552cd4ae316ac600cf1a2c5344e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 20:10:32 -0400 Subject: [PATCH 09/26] feat(studio): add manual DOM editing inspector --- .../studio-manual-dom-editing.mdx | 377 ++++++ docs/docs.json | 3 +- packages/studio/src/App.tsx | 911 +++++++++++-- .../src/components/editor/DomEditOverlay.tsx | 371 +++++ .../src/components/editor/PropertyPanel.tsx | 1196 ++++++++++++++--- .../src/components/editor/colorValue.test.ts | 49 + .../src/components/editor/colorValue.ts | 85 ++ .../src/components/editor/domEditing.test.ts | 269 ++++ .../src/components/editor/domEditing.ts | 508 +++++++ .../components/editor/gradientValue.test.ts | 89 ++ .../src/components/editor/gradientValue.ts | 336 +++++ .../studio/src/components/nle/NLELayout.tsx | 13 +- .../studio/src/components/nle/NLEPreview.tsx | 55 +- .../src/components/sidebar/LeftSidebar.tsx | 71 +- .../studio/src/player/components/Player.tsx | 88 +- .../src/player/components/Timeline.test.ts | 10 - .../studio/src/player/components/Timeline.tsx | 2 +- .../src/player/components/TimelineClip.tsx | 27 +- .../src/player/hooks/useTimelinePlayer.ts | 15 - .../studio/src/utils/sourcePatcher.test.ts | 69 +- packages/studio/src/utils/sourcePatcher.ts | 52 +- 21 files changed, 4138 insertions(+), 458 deletions(-) create mode 100644 docs/contributing/studio-manual-dom-editing.mdx create mode 100644 packages/studio/src/components/editor/DomEditOverlay.tsx create mode 100644 packages/studio/src/components/editor/colorValue.test.ts create mode 100644 packages/studio/src/components/editor/colorValue.ts create mode 100644 packages/studio/src/components/editor/domEditing.test.ts create mode 100644 packages/studio/src/components/editor/domEditing.ts create mode 100644 packages/studio/src/components/editor/gradientValue.test.ts create mode 100644 packages/studio/src/components/editor/gradientValue.ts diff --git a/docs/contributing/studio-manual-dom-editing.mdx b/docs/contributing/studio-manual-dom-editing.mdx new file mode 100644 index 000000000..55edd7193 --- /dev/null +++ b/docs/contributing/studio-manual-dom-editing.mdx @@ -0,0 +1,377 @@ +--- +title: Studio Manual DOM Editing +description: Design spec for direct-manipulation DOM editing in HyperFrames Studio. +--- + +This document is the implementation spec for adding manual DOM editing to HyperFrames Studio. The goal is to let users select, move, resize, and later edit supported elements directly on the preview canvas without inventing a new document model or pretending that every DOM node is safely editable. + +## Why This Exists + +Studio already supports **timeline editing** by patching source HTML through stable targets such as `id`, `selector`, `selectorIndex`, and `sourceFile`. What it does not support yet is **canvas-first direct manipulation** of DOM elements in the preview itself. + +The missing behavior is: + +- click an element in the preview and see selection chrome +- drag supported elements directly on canvas +- resize supported elements directly on canvas +- later, support inline text editing for text nodes we can round-trip safely + +The important constraint is that Studio should only expose actions it can persist deterministically. If the framework cannot round-trip a change to source HTML with predictable semantics, that action should stay disabled or hidden. + +## Existing Foundations In Studio + +HyperFrames Studio already has most of the persistence and identity plumbing needed for manual DOM editing: + +- `packages/studio/src/player/hooks/useTimelinePlayer.ts` + - builds patchable timeline elements from runtime/DOM state + - already exposes `domId`, `selector`, `selectorIndex`, and `sourceFile` +- `packages/studio/src/utils/sourcePatcher.ts` + - already supports attribute, inline-style, and text-content patch operations +- `packages/studio/src/App.tsx` + - already applies timeline move/resize edits by patching source and refreshing preview +- `packages/studio/src/components/nle/NLEPreview.tsx` +- `packages/studio/src/player/components/Player.tsx` + - already expose the preview iframe, which is the correct canvas boundary for direct manipulation + +That means manual DOM editing does **not** require a new persistence engine. The correct scope is: + +1. add a selection and transform layer on top of the preview iframe +2. translate supported interactions into existing patch operations +3. gate actions behind an explicit capability model + +## Open-Source Research + +We explicitly do **not** want to reinvent the low-level drag/resize interaction layer. + +### 1. Moveable + +Source: [daybrush/moveable](https://github.com/daybrush/moveable) + +Why it matters: + +- purpose-built for draggable, resizable, scalable, rotatable, groupable, and snappable DOM interactions +- has mature event hooks for drag and resize lifecycles +- explicitly supports iframe contexts in its changelog and API surface +- pairs naturally with [daybrush/selecto](https://github.com/daybrush/selecto) for future marquee or multi-select workflows + +Why it is the best fit: + +- HyperFrames Studio needs a **low-level transform primitive**, not a whole page builder +- Moveable can run against real DOM targets while Studio keeps source-of-truth ownership +- it is incremental: we can start with move + resize only + +### 2. GrapesJS + +Source: [GrapesJS/grapesjs](https://github.com/GrapesJS/grapesjs) + +Why it matters: + +- strong reference for iframe-based editor canvas architecture +- keeps editor overlays and selection chrome outside user content +- component model and canvas UX show how to avoid mutating the live DOM naively + +What we should borrow: + +- selection overlays should live in editor chrome, not inside the authored content +- explicit “enter component” / drill-down behavior is better than pretending nested content is always directly editable +- iframe editing needs careful pointer/event management + +What we should **not** do: + +- adopt GrapesJS as a dependency or new editor model +- replace HyperFrames’ source patcher and runtime with a second component tree + +### 3. Plasmic + +Source: [plasmicapp/plasmic](https://github.com/plasmicapp/plasmic) + +Why it matters: + +- strong reference for codebase-integrated visual editing +- useful example of capability-driven editing rather than “everything is editable” + +What we should borrow: + +- only expose actions for things the system can own end to end +- treat visual editing as a thin layer over a codebase, not as a replacement for it + +What we should **not** do: + +- bring in a heavyweight app-builder dependency +- add a second abstract source-of-truth beside authored HTML + +## Recommendation + +Use the following combination: + +- **Moveable** as the transform interaction primitive +- **Selecto** later, if we add multi-select or marquee selection +- **GrapesJS-like canvas architecture** for overlays and iframe behavior +- **Plasmic-like capability gating** so Studio edits only what it fully owns + +This gives HyperFrames Studio a focused direct-manipulation layer without turning Studio into a general-purpose website builder. + +## Goals + +### Phase-1 goals + +- select supported DOM elements directly in the preview +- show editor-owned selection chrome around the selected element +- move supported elements directly on canvas +- resize supported elements directly on canvas +- persist those edits back to source HTML with the existing patch pipeline +- keep preview and source synchronized after each edit + +### Later goals + +- inline text editing for safely patchable text nodes +- snapping and alignment guides +- multi-select +- keyboard nudging +- constraint handles for aspect ratio / edge pinning + +## Non-Goals + +These are explicitly out of scope for the first manual DOM editing rollout: + +- freeform editing of arbitrary DOM nodes +- rotation, warp, or arbitrary transform editing +- editing GSAP timing semantics directly from canvas +- rewriting authored layout systems like flex or grid into absolute positioning +- editing nested subcomposition internals from the master preview without explicit drill-down +- changing CSS stacking behavior that Studio does not own + +## Core Product Rule + +Studio should only expose manual DOM actions when it has **100% deterministic control** of the round trip. + +That means a direct-manipulation action is allowed only when all of the following are true: + +1. Studio can identify a stable patch target + - `domId`, or + - `selector + selectorIndex` +2. Studio can express the edit with a supported patch operation + - inline style + - attribute change + - text-content patch +3. The change has stable meaning in authored HTML + - for example, updating `left/top/width/height` on an absolutely positioned element + +If any of those are false, Studio should not show transform handles for that element. + +## Capability Model + +Manual DOM editing should use a dedicated capability object, separate from timeline-only capabilities. + +```ts +type DomEditCapabilities = { + canSelect: boolean; + canMove: boolean; + canResize: boolean; + canEditText: boolean; + canRotate: boolean; + reasonIfDisabled?: string; +}; +``` + +### Phase-1 rules + +An element is manually editable only if: + +- it is patchable via `domId` or `selector + selectorIndex` +- it resolves to a concrete DOM node in the preview iframe +- it uses a layout model we can safely patch + +Phase-1 supported layout model: + +- absolutely positioned elements with patchable `left`, `top`, `width`, or `height` + +Phase-1 unsupported examples: + +- flex/grid children whose position is emergent from parent layout +- elements whose visible geometry is controlled entirely by transforms or GSAP +- nodes rendered inside nested composition hosts unless the user drills into that composition + +## Proposed User Experience + +### Selection + +- single click on a supported element selects it +- Studio draws the selection box in editor chrome above the iframe +- selection does not inject editor markup into user content + +### Move + +- drag the selected element on canvas +- Studio shows live transform feedback +- on commit, Studio patches only the supported position properties + +### Resize + +- show resize handles only when the element’s size can be round-tripped safely +- on commit, Studio patches only the supported size properties + +### Text editing + +Not in phase 1, but the future behavior should be: + +- double click text content to enter inline edit mode +- only enable when Studio can patch that exact text node deterministically + +### Nested compositions + +- master preview can select composition hosts +- double click on a composition host should drill down into that subcomposition +- direct editing of inner subcomposition nodes should happen in the subcomposition view, not through the master preview + +## Architecture + +### 1. Canvas selection bridge + +Studio already has access to the preview iframe. Add a canvas-selection controller that: + +- observes pointer events from the preview iframe +- resolves the clicked node to a patchable Studio element +- computes the target DOM rect in viewport coordinates +- forwards selection state to the Studio shell + +### 2. Overlay layer outside the iframe + +Selection chrome should render in Studio, not inside the authored composition. + +That overlay owns: + +- selection bounding box +- resize handles +- hover outlines +- snapping guides later + +This keeps authored HTML clean and avoids polluting exports. + +### 3. Transform engine + +Use `Moveable` as the transform engine for: + +- drag start / drag / drag end +- resize start / resize / resize end + +Initial configuration should disable every behavior we do not support: + +- `draggable: true` +- `resizable: true` +- `rotatable: false` +- `scalable: false` +- `warpable: false` +- `snappable: false` initially + +### 4. Patch translation + +Moveable events should translate into source patches, not direct long-lived DOM mutation. + +Phase-1 mapping: + +- move + - patch inline `left` + - patch inline `top` +- resize + - patch inline `width` + - patch inline `height` + +During interaction, the overlay can preview live geometry. On commit, Studio should patch source and refresh the preview from source-of-truth HTML. + +### 5. Capability gating + +Before attaching Moveable to a node, Studio must evaluate the capability model. + +If unsupported: + +- allow normal selection if useful +- do not show move or resize handles +- surface the reason in the property panel or inspector + +## Data Flow + +1. user clicks an element in the preview iframe +2. Studio resolves the DOM node to a patchable target +3. Studio computes `DomEditCapabilities` +4. Studio mounts selection chrome and, if allowed, Moveable +5. user drags or resizes +6. Studio previews geometry in the overlay +7. on commit, Studio converts the interaction into `applyPatchByTarget(...)` +8. source HTML updates +9. preview hot refreshes +10. selection is reattached using the same stable target identity + +## Why We Should Not Build This From Scratch + +A custom transform engine would require us to own: + +- drag math +- resize handles +- hit testing +- nested transforms +- snapping +- pointer capture +- iframe coordinate translation + +That is exactly the kind of mature, low-level interaction surface that is easy to get 80% right and expensive to harden. `Moveable` already solves most of that category, so HyperFrames should focus on: + +- capability gating +- source patching +- iframe/editor integration +- deterministic semantics + +## Rollout Plan + +### Phase 0: internal wiring + +- add `DomEditCapabilities` +- add preview selection state +- add overlay layer anchored to the preview iframe + +### Phase 1: move + resize + +- support only absolutely positioned patchable elements +- use Moveable for drag and resize +- commit edits via `applyPatchByTarget(...)` + +### Phase 2: text editing + +- enable inline text editing for patchable text nodes +- preserve exact text-node targeting + +### Phase 3: quality of life + +- snapping +- alignment guides +- keyboard nudging +- multi-select via Selecto + +## Validation Plan + +Before shipping phase 1, verify all of the following: + +- selecting an editable node shows correct overlay bounds +- moving an editable node updates source HTML and survives refresh +- resizing an editable node updates source HTML and survives refresh +- unsupported nodes never show misleading handles +- nested composition hosts drill down instead of exposing invalid inner editing +- preview and render stay WYSIWYG after edits + +## Open Questions + +- should phase 1 patch inline styles only, or also simple positional attributes when present? +- should selection default to the nearest patchable ancestor when the exact clicked node is not editable? +- should master-view composition hosts be movable directly, or only editable after drill-down? +- when a node is unsupported, should Studio show an explanation in the property panel, or simply omit handles? + +## Bottom Line + +The right approach is **not** “make the whole DOM editable.” The right approach is: + +- keep authored HTML as the source of truth +- add a thin iframe-aware overlay layer +- use `Moveable` as the transform primitive +- borrow canvas/drill-down behavior from tools like GrapesJS +- borrow capability boundaries from tools like Plasmic +- only expose manual DOM actions that Studio can round-trip with deterministic meaning diff --git a/docs/docs.json b/docs/docs.json index 8548fd0b1..ebdea31c9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -196,7 +196,8 @@ "pages": [ "contributing", "contributing/release-channels", - "contributing/testing-local-changes" + "contributing/testing-local-changes", + "contributing/studio-manual-dom-editing" ] } ] diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 605f82731..1872e2ef6 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,7 +1,6 @@ import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react"; import { useMountEffect } from "./hooks/useMountEffect"; import { NLELayout } from "./components/nle/NLELayout"; -import { TimelineEditorNotice } from "./components/nle/TimelineEditorNotice"; import { SourceEditor } from "./components/editor/SourceEditor"; import { LeftSidebar } from "./components/sidebar/LeftSidebar"; import { RenderQueue } from "./components/renders/RenderQueue"; @@ -28,7 +27,11 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline"; import { useCaptionStore } from "./captions/store"; import { useCaptionSync } from "./captions/hooks/useCaptionSync"; import { parseCaptionComposition } from "./captions/parser"; -import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher"; +import { + applyPatchByTarget, + readAttributeByTarget, + readTagSnippetByTarget, +} from "./utils/sourcePatcher"; import { buildTrackZIndexMap, formatTimelineAttributeNumber, @@ -37,24 +40,287 @@ import { getNextTimelineZoomPercent, getTimelineZoomPercent, } from "./player/components/timelineZoom"; -<<<<<<< HEAD import { + TIMELINE_TOGGLE_SHORTCUT_LABEL, getTimelineEditorHintDismissed, getTimelineToggleTitle, setTimelineEditorHintDismissed, shouldHandleTimelineToggleHotkey, } from "./utils/timelineDiscovery"; -======= ->>>>>>> b52f8e16 (feat: use percentage-based timeline zoom) +import { PropertyPanel } from "./components/editor/PropertyPanel"; +import { DomEditOverlay } from "./components/editor/DomEditOverlay"; +import { + buildDomEditMovePatchOperations, + buildDomEditResizePatchOperations, + buildDomEditStylePatchOperation, + buildDomEditTextPatchOperation, + buildElementAgentPrompt, + findElementForSelection, + isTextEditableSelection, + resolveDomEditCapabilities, + resolveDomEditSelection, + type DomEditSelection, +} from "./components/editor/domEditing"; interface EditingFile { path: string; content: string | null; } -interface AppToast { - message: string; - tone: "error" | "info"; +type RightPanelTab = "design" | "renders"; +type FocusedDesignSection = "position" | "styles" | null; + +function normalizeDomEditStyleValue(property: string, value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + + if ( + ["left", "top", "width", "height", "border-radius", "font-size"].includes(property) && + /^-?\d+(\.\d+)?$/.test(trimmed) + ) { + return `${trimmed}px`; + } + + return trimmed; +} + +function getEventTargetElement(target: EventTarget | null): HTMLElement | null { + if (!target || typeof target !== "object") return null; + const maybeNode = target as { + nodeType?: number; + parentElement?: Element | null; + }; + if (maybeNode.nodeType === 1) return target as HTMLElement; + if (maybeNode.nodeType === 3 && maybeNode.parentElement) { + return maybeNode.parentElement as HTMLElement; + } + return null; +} + +function findMatchingTimelineElementId( + selection: Pick< + DomEditSelection, + "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost" + >, + elements: TimelineElement[], +): string | null { + for (const element of elements) { + if (selection.id && element.domId === selection.id) { + return element.key ?? element.id; + } + if ( + selection.isCompositionHost && + selection.compositionSrc && + element.compositionSrc === selection.compositionSrc + ) { + return element.key ?? element.id; + } + if ( + selection.selector && + element.selector === selection.selector && + (element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) && + (element.sourceFile ?? "index.html") === selection.sourceFile + ) { + return element.key ?? element.id; + } + } + + return null; +} + +function findMappedCompositionHost( + target: HTMLElement, + timelineElements: TimelineElement[], + compIdToSrc: Map, + fileTree: string[], +): { host: HTMLElement; compositionSrc: string } | null { + const rootCompositionId = + target.ownerDocument + .querySelector("[data-composition-id]") + ?.getAttribute("data-composition-id") ?? null; + + let nestedCurrent: HTMLElement | null = target; + while (nestedCurrent) { + const nestedCompId = nestedCurrent.getAttribute("data-composition-id"); + if (nestedCompId && nestedCompId !== rootCompositionId) { + const hostCandidate = nestedCurrent.parentElement?.closest(".clip"); + if (hostCandidate instanceof HTMLElement) { + const hostCompId = hostCandidate.getAttribute("data-composition-id"); + const compositionSrc = + hostCandidate.getAttribute("data-composition-src") ?? + hostCandidate.getAttribute("data-composition-file") ?? + (hostCompId ? compIdToSrc.get(hostCompId) : undefined) ?? + compIdToSrc.get(nestedCompId) ?? + fileTree.find((path) => path.endsWith(`${nestedCompId}.html`)) ?? + undefined; + if (compositionSrc) { + return { host: hostCandidate, compositionSrc }; + } + } + } + nestedCurrent = nestedCurrent.parentElement; + } + + let current: HTMLElement | null = target; + while (current) { + const compId = current.getAttribute("data-composition-id"); + const directSrc = + current.getAttribute("data-composition-src") ?? + current.getAttribute("data-composition-file") ?? + undefined; + const timelineMatch = + timelineElements.find( + (element) => + Boolean(element.compositionSrc) && + (element.domId === current?.id || + (current?.id && element.id === current.id) || + (compId && element.id === compId)), + ) ?? null; + const compositionSrc = + directSrc ?? + timelineMatch?.compositionSrc ?? + (compId ? compIdToSrc.get(compId) : undefined) ?? + (compId ? fileTree.find((path) => path.endsWith(`${compId}.html`)) : undefined); + if (compositionSrc) { + return { host: current, compositionSrc }; + } + current = current.parentElement; + } + + return null; +} + +function isMoveStyleProperty(property: string): boolean { + return property === "left" || property === "top"; +} + +function isResizeStyleProperty(property: string): boolean { + return property === "width" || property === "height"; +} + +function getDomSelectionClickKey( + selection: Pick, +): string { + if (selection.id) return `id:${selection.id}`; + return `${selection.selector ?? "unknown"}:${selection.selectorIndex ?? 0}`; +} + +function getPreviewTargetFromPointer( + iframe: HTMLIFrameElement, + clientX: number, + clientY: number, +): HTMLElement | null { + let doc: Document | null = null; + let win: Window | null = null; + try { + doc = iframe.contentDocument; + win = iframe.contentWindow; + } catch { + return null; + } + if (!doc || !win) return null; + + const iframeRect = iframe.getBoundingClientRect(); + const root = + doc.querySelector("[data-composition-id]") ?? doc.documentElement ?? null; + const rootRect = root?.getBoundingClientRect(); + const rootWidth = rootRect?.width || win.innerWidth; + const rootHeight = rootRect?.height || win.innerHeight; + if (!rootWidth || !rootHeight) return null; + + const scaleX = iframeRect.width / rootWidth; + const scaleY = iframeRect.height / rootHeight; + const localX = (clientX - iframeRect.left) / scaleX; + const localY = (clientY - iframeRect.top) / scaleY; + + return getEventTargetElement(doc.elementFromPoint(localX, localY)); +} + +// ── Ask Agent Modal ── + +function AskAgentModal({ + selectionLabel, + onSubmit, + onClose, +}: { + selectionLabel: string; + onSubmit: (instruction: string) => void; + onClose: () => void; +}) { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + useMountEffect(() => { + requestAnimationFrame(() => inputRef.current?.focus()); + }); + + const handleSubmit = () => { + if (!value.trim()) return; + onSubmit(value.trim()); + }; + + return ( +
+
e.stopPropagation()} + > +
+
+

Ask agent

+

+ {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel} +

+
+ +
+
+