Skip to content
Open
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
19 changes: 15 additions & 4 deletions packages/sdk/src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
import { findRoot, getElementStyles } from "./engine/model.js";
import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js";
import type { HyperFramesElement, SdkDocument } from "./types.js";

// Tags that carry no editable content and must not enter the element tree.
Expand Down Expand Up @@ -38,13 +38,23 @@ function ownText(el: Element): string | null {
}

// fallow-ignore-next-line complexity
function buildElement(el: Element): HyperFramesElement | null {
function buildElement(el: Element, scopePrefix: string): HyperFramesElement | null {
const tag = el.tagName.toLowerCase();
if (EXCLUDED_TAGS.has(tag)) return null;

const id = el.getAttribute("data-hf-id") ?? "";
if (!id) return null; // should never happen after ensureHfIds, but guard defensively

// scopedId: if we're inside a sub-comp scope, prefix with "scopePrefix/".
// The host element itself is in the PARENT scope (no prefix change for its own id).
const scopedId = scopePrefix ? `${scopePrefix}/${id}` : id;

// Children inherit the scope prefix from their parent.
// If this element is a new host boundary (starts a new sub-comp scope), its
// children use THIS element's scopedId as their prefix.
// Otherwise, children inherit the same prefix that this element used.
const childPrefix = isNewHostBoundary(el) ? scopedId : scopePrefix;

const inlineStyles = getElementStyles(el);

const classAttr = el.getAttribute("class") ?? "";
Expand Down Expand Up @@ -72,12 +82,13 @@ function buildElement(el: Element): HyperFramesElement | null {

const children: HyperFramesElement[] = [];
for (const child of Array.from(el.children)) {
const built = buildElement(child);
const built = buildElement(child, childPrefix);
if (built) children.push(built);
}

return {
id,
scopedId,
tag,
children,
inlineStyles,
Expand Down Expand Up @@ -142,7 +153,7 @@ export function buildRoots(document: Document): HyperFramesElement[] {
const roots: HyperFramesElement[] = [];
if (body) {
for (const child of Array.from(body.children)) {
const built = buildElement(child);
const built = buildElement(child, "");
if (built) roots.push(built);
}
}
Expand Down
43 changes: 43 additions & 0 deletions packages/sdk/src/engine/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,49 @@ export function findById(document: Document, id: string): Element | null {
return document.querySelector(`[data-hf-id="${escaped}"]`);
}

function escapeHfId(id: string): string {
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

/**
* Resolve a bare or scoped hf-id to its DOM element.
*
* Bare id ("hf-x"): equivalent to findById — top-level document search.
* Scoped id ("hf-HOST/hf-LEAF", any depth): each segment narrows the search
* into the subtree of the previous match. This unambiguously addresses an
* element inside a sub-composition even when bare ids collide.
*/
export function resolveScoped(document: Document, id: string): Element | null {
const parts = id.split("/");
let context: Element | Document = document;
for (const part of parts) {
const escaped = escapeHfId(part);
const found: Element | null =
context === document
? (context as Document).querySelector(`[data-hf-id="${escaped}"]`)
: (context as Element).querySelector(`[data-hf-id="${escaped}"]`);
if (!found) return null;
context = found;
}
return context as Element;
}

/**
* Returns true when this element starts a new sub-composition scope — i.e. it
* is a host element (has data-composition-file) and is NOT the outerHTML
* innerRoot of the SAME sub-composition (same dcf value as parent).
*
* outerHTML case: both host and innerRoot carry data-composition-file="sub.html".
* The innerRoot has the SAME value as the host (its parent) → not a new boundary.
* A genuine nested host inside a sub-comp has a DIFFERENT dcf value.
*/
export function isNewHostBoundary(el: Element): boolean {
const dcf = el.getAttribute("data-composition-file");
if (!dcf) return false;
const parentDcf = el.parentElement?.getAttribute("data-composition-file") ?? null;
return dcf !== parentDcf;
}

export function findRoot(document: Document): Element | null {
return (
document.querySelector("[data-hf-root]") ??
Expand Down
38 changes: 29 additions & 9 deletions packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import type { CanResult, EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js";
import type { ParsedDocument } from "./model.js";
import {
findById,
resolveScoped,
findRoot,
getElementStyles,
setElementStyles,
Expand Down Expand Up @@ -195,7 +195,7 @@ function handleSetStyle(
): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const old = getElementStyles(el);
setElementStyles(el, styles);
Expand Down Expand Up @@ -235,7 +235,7 @@ function handleMoveElement(
function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldText = getOwnText(el);
setOwnText(el, value);
Expand All @@ -260,7 +260,7 @@ function handleSetAttribute(
validateSetAttribute(name, value);
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const oldValue = el.getAttribute(name);
const path = attrPath(id, name);
Expand Down Expand Up @@ -293,7 +293,7 @@ function handleSetTiming(
let currentScript = origScript;

for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;

const oldStartStr = el.getAttribute("data-start");
Expand Down Expand Up @@ -373,7 +373,7 @@ function handleSetHold(
): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;

const fields: Array<["start" | "end" | "fill", string]> = [
Expand Down Expand Up @@ -401,20 +401,28 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
let currentScript = origScript;

for (const id of ids) {
const el = findById(parsed.document, id);
const el = resolveScoped(parsed.document, id);
if (!el) continue;
const parentEl = el.parentElement;
const parentId = parentEl?.getAttribute("data-hf-id") ?? null;
const siblingIndex = getSiblingIndex(el);
const html = el.outerHTML;

// Collect all bare hf-ids in the subtree BEFORE removal so GSAP cascade
// removes animations targeting any sub-composition element, not just the host.
const subtreeIds = collectSubtreeHfIds(el);

el.remove();

const path = elementPath(id);
result.forward.push(patchRemove(path));
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));

if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id);
if (currentScript) {
for (const subtreeId of subtreeIds) {
currentScript = cascadeRemoveAnimations(currentScript, subtreeId);
}
}
}

if (origScript && currentScript && currentScript !== origScript) {
Expand Down Expand Up @@ -509,6 +517,18 @@ function selectorMatchesId(selector: string, id: HfId): boolean {
);
}

/** Collect all bare data-hf-id values from el and all its descendants. */
function collectSubtreeHfIds(el: Element): string[] {
const ids: string[] = [];
const own = el.getAttribute("data-hf-id");
if (own) ids.push(own);
for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) {
const id = child.getAttribute("data-hf-id");
if (id) ids.push(id);
}
return ids;
}

function cascadeRemoveAnimations(script: string, id: HfId): string {
const parsedGsap = parseGsapScriptAcornForWrite(script);
if (!parsedGsap) return script;
Expand Down Expand Up @@ -749,7 +769,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "removeElement": {
const ids = targets(op.target);
if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided.");
const missing = ids.filter((id) => findById(parsed.document, id) === null);
const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null);
if (missing.length > 0)
return canErr(
"E_TARGET_NOT_FOUND",
Expand Down
49 changes: 33 additions & 16 deletions packages/sdk/src/engine/patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,30 +30,45 @@ import type { JsonPatchOp, PatchEvent } from "../types.js";

// ─── Path builders ────────────────────────────────────────────────────────────

/**
* RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped).
* Scoped ids contain "/" which must be encoded as "~1" in a path segment.
* "~" must be encoded as "~0" first (order matters per RFC 6902 §3).
*/
function escapeIdForPath(id: string): string {
return id.replace(/~/g, "~0").replace(/\//g, "~1");
}

/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */
function decodePathSegment(segment: string): string {
// RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order)
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}

export function stylePath(id: string, prop: string): string {
return `/elements/${id}/inlineStyles/${prop}`;
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
}

export function textPath(id: string): string {
return `/elements/${id}/text`;
return `/elements/${escapeIdForPath(id)}/text`;
}

export function attrPath(id: string, name: string): string {
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
const escaped = name.replace(/~/g, "~0").replace(/\//g, "~1");
return `/elements/${id}/attributes/${escaped}`;
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
}

export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string {
return `/elements/${id}/timing/${field}`;
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
}

export function holdPath(id: string, field: "start" | "end" | "fill"): string {
return `/elements/${id}/hold/${field}`;
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
}

export function elementPath(id: string): string {
return `/elements/${id}`;
return `/elements/${escapeIdForPath(id)}`;
}

export function variablePath(id: string): string {
Expand All @@ -80,29 +95,30 @@ export function styleSheetPath(): string {
*/
export function pathToKey(path: string): string | null {
// /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}"
// id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
if (styleMatch) return `${styleMatch[1]}.style.${styleMatch[2]}`;
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;

// /elements/{id}/text → "{id}.text"
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
if (textMatch) return `${textMatch[1]}.text`;
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;

// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
if (attrMatch) return `${attrMatch[1]}.attr.${attrMatch[2]}`;
if (attrMatch) return `${decodePathSegment(attrMatch[1]!)}.attr.${attrMatch[2]}`;

// /elements/{id}/timing/{field} → "{id}.timing.{field}"
// Note: field "end" maps to the computed data-end attribute value.
const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path);
if (timingMatch) return `${timingMatch[1]}.timing.${timingMatch[2]}`;
if (timingMatch) return `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`;

// /elements/{id}/hold/{field} → "{id}.hold.{field}"
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
if (holdMatch) return `${holdMatch[1]}.hold.${holdMatch[2]}`;
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;

// /elements/{id} (whole element) → "{id}"
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
if (elemMatch) return elemMatch[1] ?? null;
if (elemMatch) return decodePathSegment(elemMatch[1]!) ?? null;

// /variables/{id} → "var.{id}"
const varMatch = /^\/variables\/(.+)$/.exec(path);
Expand Down Expand Up @@ -133,9 +149,10 @@ export function keyToPath(key: string): string | null {
if (text?.[1]) return textPath(text[1]);

const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
// pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath()
// here (it would re-escape '~' → '~0'), just reconstruct the path directly.
if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`;
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
// the already-encoded attr segment. Reconstruct manually.
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;

const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");
Expand Down
Loading
Loading