Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions packages/studio/src/components/editor/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { memo, useState, useCallback, useEffect, useRef } from "react";
import {
collectDomEditLayerItems,
getDomEditLayerKey,
liveLayerElement,
resolveDomEditSelection,
type DomEditLayerItem,
} from "./domEditing";
Expand Down Expand Up @@ -190,20 +191,8 @@ export const LayersPanel = memo(function LayersPanel() {

const resolveSelection = useCallback(
(layer: DomEditLayerItem) => {
// Re-find the element from the live DOM — layer.element may be stale
// after soft reload (which replaces scripts without reloading the iframe).
let el = layer.element;
if (!el.isConnected) {
const iframe = previewIframeRef.current;
const doc = iframe?.contentDocument;
if (doc) {
const found =
(layer.id ? doc.getElementById(layer.id) : null) ??
(layer.hfId ? doc.querySelector(`[data-hf-id="${CSS.escape(layer.hfId)}"]`) : null) ??
doc.getElementById(layer.key);
if (found instanceof HTMLElement) el = found;
}
}
const doc = previewIframeRef.current?.contentDocument;
const el = liveLayerElement(layer, doc, activeCompPath);
return resolveDomEditSelection(el, {
activeCompositionPath: activeCompPath,
isMasterView,
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/components/editor/domEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export {
getDomEditNonEditableReason,
getDomEditTargetKey,
isTextEditableSelection,
liveLayerElement,
readHfId,
refreshDomEditSelection,
resolveDomEditCapabilities,
Expand Down
27 changes: 27 additions & 0 deletions packages/studio/src/components/editor/domEditingLayers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildDomEditPatchTarget,
buildTextFieldChildLocator,
readHfId,
liveLayerElement,
} from "./domEditingLayers";
import type { DomEditTextField } from "./domEditingTypes";

Expand Down Expand Up @@ -305,3 +306,29 @@ describe("collectDomEditLayerItems selector-index cost", () => {
expect(classSelectorQueries(12)).toBe(1);
});
});

describe("liveLayerElement", () => {
it("finds a replaced layer again in its own file when a sub-composition repeats its id", () => {
document.body.innerHTML =
'<div data-composition-id="main">' +
'<div data-composition-id="strip" data-composition-src="compositions/strip.html">' +
'<div id="card-1">strip</div></div><div id="card-1">root</div></div>';
const stale = document.createElement("div");
const layer = {
key: "index.html:card-1:0",
element: stale,
label: "card-1",
tagName: "div",
depth: 0,
childCount: 0,
id: "card-1",
sourceFile: "index.html",
};

expect(liveLayerElement(layer, document, "index.html").textContent).toBe("root");
expect(
liveLayerElement({ ...layer, sourceFile: "compositions/strip.html" }, document, "index.html")
.textContent,
).toBe("strip");
});
});
9 changes: 9 additions & 0 deletions packages/studio/src/components/editor/domEditingLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,15 @@ export function collectDomEditLayerItems(
return items;
}

export function liveLayerElement(
layer: DomEditLayerItem,
doc: Document | null | undefined,
activeCompositionPath: string | null,
): HTMLElement {
if (layer.element.isConnected || !doc) return layer.element;
return findElementForSelection(doc, layer, activeCompositionPath) ?? layer.element;
}

// ─── Patch operations ────────────────────────────────────────────────────────

export function buildDomEditStylePatchOperation(
Expand Down
50 changes: 50 additions & 0 deletions packages/studio/src/player/hooks/timelineDomBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,56 @@ describe("live DOM manifest hydration", () => {
).toHaveLength(2);
}
});
it("binds a root clip to its own element when a mounted sub-composition repeats its id", () => {
// The sub-composition's copy comes first in document order, so a bare id lookup finds it.
const doc = documentWith(
'<div id="strip" data-composition-id="strip" data-composition-src="compositions/strip.html" data-start="0" data-duration="4" data-track-index="1">' +
'<video id="frame-1" data-hf-id="hf-inner" data-start="0"></video></div>' +
'<video id="frame-1" data-hf-id="hf-root" data-start="6" data-duration="1" data-track-index="7"></video>',
);
const [element] = buildTimelineElementsFromClips(
[
clip({
id: "frame-1",
tagName: "video",
kind: "video",
start: 6,
duration: 1,
track: 7,
compositionAncestors: ["main"],
}),
],
doc,
);
expect([element.hfId, element.sourceFile]).toEqual(["hf-root", undefined]);
});
it("keeps a unique id bound while its host's composition id is healed after the manifest", () => {
// The wrapper's data-composition-id arrived after the runtime recorded the clip's chain as ["main"].
const doc = documentWith(
'<div id="ho" data-hf-id="ho" data-start="0" data-duration="4"></div>' +
'<div id="hs-layer" data-composition-id="hs-comp">' +
'<div id="hs" data-hf-id="hs" data-start="0" data-duration="4"></div></div>',
);
const [element] = buildTimelineElementsFromClips(
[clip({ id: "hs", compositionAncestors: ["main"] })],
doc,
);
expect(element.hfId).toBe("hs");
});
it("binds an id-less host by its composition id over a lone id inside that sub-composition", () => {
const doc = documentWith(
'<div data-composition-id="card" data-composition-src="compositions/card.html" data-start="0" data-duration="4">' +
'<div data-hf-inner-root><div id="card" data-start="0" data-duration="4"></div></div></div>',
);
const host = doc.querySelector('[data-composition-src="compositions/card.html"]');
const card = clip({
id: "card",
kind: "composition",
compositionId: "card",
compositionAncestors: ["main"],
});
expect(findTimelineDomNodeForClip(doc, card, 0)).toBe(host);
});
it("does not bind a cross-tag direct identity", () => {
const doc = documentWith('<div id="collision" data-start="0" data-duration="4"></div>');
expect(
Expand Down
26 changes: 25 additions & 1 deletion packages/studio/src/player/hooks/timelineSyncHydration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function mountScene(): Document {

// A composition clip is keyed by its ELEMENT id, not its `data-composition-id`
// (the runtime's clip tree publishes `scene-2-slot`), so the collector resolves
// the host with getElementById(clip.id) exactly as the sibling walk does.
// the host by clip.id in the clip's own composition, exactly as the sibling walk does.
const sceneClips = [clip({ id: "scene-2-slot", kind: "composition", compositionId: "scene-2" })];

describe("collectSubCompositionHostState", () => {
Expand Down Expand Up @@ -83,6 +83,30 @@ describe("collectSubCompositionHostState", () => {
expect(state.get("scene-2-video")?.hidden).toBe(true);
});

it("reads its own host when an earlier sub-composition repeats the host's id", () => {
document.body.innerHTML = `
<main data-composition-id="main">
<div id="intro-slot" data-composition-id="intro" data-composition-src="intro.html">
<div data-hf-inner-root data-composition-id="intro">
<div id="scene-2-slot"><p id="intro-note" data-hidden></p></div>
</div>
</div>
<div id="scene-2-slot" data-composition-id="scene-2" data-composition-src="scene-2.html">
<div data-hf-inner-root><div id="scene-2-title" data-hidden></div></div>
</div>
</main>`;
const host = clip({
id: "scene-2-slot",
kind: "composition",
compositionId: "scene-2",
compositionAncestors: ["main"],
});

expect([...collectSubCompositionHostState(document, [host]).keys()]).toEqual(["scene-2-title"]);
const rows = collectSubCompositionDomChildren(document, [host], new Map());
expect(rows.map((child) => child.id)).toEqual(["scene-2-title"]);
});

it("returns empty without a document, rather than throwing", () => {
expect(collectSubCompositionHostState(null, sceneClips).size).toBe(0);
});
Expand Down
6 changes: 3 additions & 3 deletions packages/studio/src/player/hooks/timelineSyncHydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* an argument, so each is callable — and readable — on its own.
*/

import { createTimelineDomNodeResolver } from "../lib/timelineElementHelpers";
import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timelineElementHelpers";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
Expand Down Expand Up @@ -128,7 +128,7 @@ export function collectSubCompositionDomChildren(
if (!iframeDoc) return out;
for (const clip of clips) {
if (clip.kind !== "composition" || !clip.id) continue;
const hostEl = iframeDoc.getElementById(clip.id);
const hostEl = findClipElementById(iframeDoc, clip);
if (!hostEl) continue;
const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out);
Expand Down Expand Up @@ -172,7 +172,7 @@ export function collectSubCompositionHostState(
if (!iframeDoc) return out;
for (const clip of clips) {
if (clip.kind !== "composition" || !clip.id) continue;
const hostEl = iframeDoc.getElementById(clip.id);
const hostEl = findClipElementById(iframeDoc, clip);
if (!hostEl) continue;
for (const el of Array.from(hostEl.querySelectorAll("[id]"))) {
const state = readSubCompositionHostState(el);
Expand Down
54 changes: 45 additions & 9 deletions packages/studio/src/player/lib/timelineElementHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,14 +428,50 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
});
}

function findTimelineDomNode(doc: Document, id: string): Element | null {
return (
doc.getElementById(id) ??
doc.querySelector(`[data-hf-id="${CSS.escape(id)}"]`) ??
doc.querySelector(`[data-composition-id="${CSS.escape(id)}"]`) ??
doc.querySelector(`.${CSS.escape(id)}`) ??
null
);
/** Whether `node` sits in the composition the clip was read from, the chain the runtime records outermost first
* (`resolveNearestCompositionContext` in core's runtime/timeline.ts). A clip without that scope accepts any node. */
function nodeInClipScope(node: Element, clip: ClipManifestClip): boolean {
const scope = clip.compositionAncestors;
if (!scope) return true;
const ids: string[] = [];
for (let cursor = node.parentElement; cursor; cursor = cursor.parentElement) {
const id = cursor.getAttribute("data-composition-id");
if (id) ids.unshift(id);
}
return ids.length === scope.length && ids.every((id, index) => id === scope[index]);
}

/** The first match in the clip's composition across `selectors`; a lone match stands only when none is in scope, as a
* healed host can stale the clip's chain for a pass. An id can repeat in a sub-composition earlier in the document. */
function findInClipScope(
doc: Document,
clip: ClipManifestClip,
selectors: string[],
): Element | null {
let lone: Element | null = null;
for (const selector of selectors) {
const nodes = Array.from(doc.querySelectorAll(selector));
const scoped = nodes.find((node) => nodeInClipScope(node, clip));
if (scoped) return scoped;
if (nodes.length === 1) lone ??= nodes[0];
}
return lone;
}

export function findClipElementById(doc: Document, clip: ClipManifestClip): Element | null {
if (!clip.id) return null;
const first = doc.getElementById(clip.id);
if (!first || nodeInClipScope(first, clip)) return first;
return findInClipScope(doc, clip, [`[id="${CSS.escape(clip.id)}"]`]);
}

function findTimelineDomNode(doc: Document, clip: ClipManifestClip): Element | null {
if (!clip.id) return null;
const first = doc.getElementById(clip.id);
if (first && nodeInClipScope(first, clip)) return first;
const id = CSS.escape(clip.id);
const byOtherKeys = [`[data-hf-id="${id}"]`, `[data-composition-id="${id}"]`, `.${id}`];
return findInClipScope(doc, clip, first ? [`[id="${id}"]`, ...byOtherKeys] : byOtherKeys);
}

export function findTimelineDomNodeForClip(
Expand All @@ -445,7 +481,7 @@ export function findTimelineDomNodeForClip(
usedNodes = new Set<Element>(),
getCandidates = () => getTimelineDomNodes(doc),
): Element | null {
const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
const byIdentity = findTimelineDomNode(doc, clip);
if (byIdentity && !usedNodes.has(byIdentity) && nodeMatchesClipTag(byIdentity, clip))
return byIdentity;

Expand Down
Loading