@hyperframes/sdk 0.8.190 → 0.8.192

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,4 +16,9 @@ import type { ParsedDocument } from "./model.js";
16
16
  * property is a no-op in applyOne, so this is safe against fresh-base misses.)
17
17
  */
18
18
  export declare function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void;
19
- export declare function applyPatchesToDocument(parsed: ParsedDocument, patches: readonly JsonPatchOp[]): void;
19
+ interface ApplyOptions {
20
+ /** Keep a copy of each removed element an inline script looks up (stored deletes replayed onto a base). */
21
+ keepScriptTargets?: boolean;
22
+ }
23
+ export declare function applyPatchesToDocument(parsed: ParsedDocument, patches: readonly JsonPatchOp[], options?: ApplyOptions): void;
24
+ export {};
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { findById, findRoot, declarationElement, setElementStyles, setOwnText, setGsapScript, setStyleSheet, } from "./model.js";
11
11
  import { keyToPath, stylePath } from "./patches.js";
12
+ import { scriptLooksUp } from "./scriptLookups.js";
12
13
  import { writeVariableDefault, clearVariableDefault, writeVariableDeclaration, removeVariableDeclarationEntry, } from "./variableModel.js";
13
14
  function isRawDeclarationEntry(value) {
14
15
  return (typeof value === "object" &&
@@ -120,9 +121,19 @@ export function applyOverrideSet(parsed, overrides) {
120
121
  patches.push({ op: "remove", path: stylePath(rootId, `--${key.slice("var.".length)}`) });
121
122
  }
122
123
  }
123
- applyPatchesToDocument(parsed, patches);
124
+ applyPatchesToDocument(parsed, patches, { keepScriptTargets: true });
124
125
  }
125
- export function applyPatchesToDocument(parsed, patches) {
126
+ /** Keep a deleted node a script looks up: serialize writes it back hidden, so the script finds it. */
127
+ function keepDelete(parsed, el) {
128
+ const parent = el.parentElement;
129
+ (parsed.keptDeletes ??= []).push({
130
+ hfId: el.getAttribute("data-hf-id") ?? "",
131
+ html: el.outerHTML,
132
+ parentId: parent?.getAttribute("data-hf-id") ?? null,
133
+ siblingIndex: parent ? Array.from(parent.children).indexOf(el) : 0,
134
+ });
135
+ }
136
+ export function applyPatchesToDocument(parsed, patches, options = {}) {
126
137
  for (const patch of patches) {
127
138
  const p = parsePath(patch.path);
128
139
  if (!p)
@@ -130,11 +141,11 @@ export function applyPatchesToDocument(parsed, patches) {
130
141
  if (p.id && patch.path.startsWith("/elements/")) {
131
142
  p.id = p.id.replace(/~1/g, "/").replace(/~0/g, "~");
132
143
  }
133
- applyOne(parsed, patch, p);
144
+ applyOne(parsed, patch, p, options);
134
145
  }
135
146
  }
136
147
  // fallow-ignore-next-line complexity
137
- function applyOne(parsed, patch, p) {
148
+ function applyOne(parsed, patch, p, options) {
138
149
  switch (p.type) {
139
150
  case "style": {
140
151
  const el = p.id ? findById(parsed.document, p.id) : null;
@@ -220,6 +231,8 @@ function applyOne(parsed, patch, p) {
220
231
  return;
221
232
  if (patch.op === "remove") {
222
233
  const el = findById(parsed.document, p.id);
234
+ if (el && options.keepScriptTargets && scriptLooksUp(parsed.document, el))
235
+ keepDelete(parsed, el);
223
236
  el?.remove();
224
237
  }
225
238
  else if (patch.op === "add" && patch.value) {
@@ -10,6 +10,14 @@ export interface ParsedDocument {
10
10
  wrapped: boolean;
11
11
  /** ensureHfIds-stamped original HTML — used as fallback / diff base. */
12
12
  stamped: string;
13
+ /** Replayed deletes a script still looks up: left out of the live document, written back hidden by serialize. */
14
+ keptDeletes?: KeptDelete[];
15
+ }
16
+ export interface KeptDelete {
17
+ hfId: string;
18
+ html: string;
19
+ parentId: string | null;
20
+ siblingIndex: number;
13
21
  }
14
22
  export declare function parseMutable(html: string): ParsedDocument;
15
23
  export declare function findById(document: Document, id: string): Element | null;
@@ -0,0 +1 @@
1
+ export declare function scriptLooksUp(document: Document, el: Element): boolean;
@@ -0,0 +1,48 @@
1
+ // Whether an inline script could look the element up. A replayed delete of such a node is hidden,
2
+ // not removed: a missing node makes the script throw before the timeline registers.
3
+ const LITERAL_LOOKUP = /(getElementById|getElementsByClassName|getElementsByTagName|querySelector(?:All)?)\s*\(\s*(["'`])([\s\S]*?)\2\s*(\S?)/g;
4
+ // A lookup whose argument is a variable or an expression: it can name any node.
5
+ const DYNAMIC_LOOKUP = /(?:getElementById|getElementsByClassName|getElementsByTagName|querySelector(?:All)?)\s*\(\s*(?!["'`)\s])/;
6
+ const PLACEHOLDER = /\$\{[^}]*\}/;
7
+ function idPattern(text) {
8
+ const parts = text.split(new RegExp(PLACEHOLDER.source, "g"));
9
+ const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
10
+ return new RegExp(`^${escaped.join("[\\w-]+")}$`);
11
+ }
12
+ function matchesSelector(el, selector) {
13
+ if (PLACEHOLDER.test(selector))
14
+ return true;
15
+ try {
16
+ return el.matches(selector) || el.querySelector(selector) !== null;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function lookupReaches(el, method, argument) {
23
+ if (PLACEHOLDER.test(argument))
24
+ return true;
25
+ const nodes = [el, ...Array.from(el.querySelectorAll("*"))];
26
+ if (method === "getElementById") {
27
+ const pattern = idPattern(argument);
28
+ return nodes.some((node) => pattern.test(node.getAttribute("id") ?? "\0"));
29
+ }
30
+ if (method === "getElementsByClassName") {
31
+ const wanted = argument.split(/\s+/).filter(Boolean);
32
+ return nodes.some((node) => wanted.every((name) => node.classList.contains(name)));
33
+ }
34
+ if (method === "getElementsByTagName") {
35
+ return argument === "*" || nodes.some((node) => node.localName === argument.toLowerCase());
36
+ }
37
+ return matchesSelector(el, argument);
38
+ }
39
+ export function scriptLooksUp(document, el) {
40
+ const scripts = Array.from(document.querySelectorAll("script:not([src])"), (s) => s.textContent ?? "").join("\n");
41
+ if (DYNAMIC_LOOKUP.test(scripts))
42
+ return true;
43
+ for (const [, method, , argument, next] of scripts.matchAll(LITERAL_LOOKUP)) {
44
+ if (next !== ")" || lookupReaches(el, method ?? "", argument ?? ""))
45
+ return true;
46
+ }
47
+ return false;
48
+ }
@@ -4,7 +4,7 @@
4
4
  * Phase 3a: generates from the live DOM. The DOM IS the mutable state.
5
5
  * Phase 3b: GSAP script section will use the meriyah/offset-splice path once available.
6
6
  */
7
- import type { ParsedDocument } from "./model.js";
7
+ import { type ParsedDocument } from "./model.js";
8
8
  /**
9
9
  * Serialize the live document back to HTML.
10
10
  *
@@ -4,6 +4,52 @@
4
4
  * Phase 3a: generates from the live DOM. The DOM IS the mutable state.
5
5
  * Phase 3b: GSAP script section will use the meriyah/offset-splice path once available.
6
6
  */
7
+ import { findById, getElementStyles, setElementStyles, } from "./model.js";
8
+ // A kept delete is not a clip: without these the renderer would still schedule its media and stretch the film.
9
+ const CLIP_ATTRIBUTES = ["data-start", "data-duration", "data-end", "data-track-index"];
10
+ // A hidden copy still gets its media fetched, and extracted when it is a video, so it keeps no source.
11
+ const MEDIA_SOURCE_ATTRIBUTES = ["src", "srcset", "poster"];
12
+ /** The deleted node, hidden and no longer a clip, so a script that looks it up still finds it. */
13
+ function hiddenCopy(doc, kept) {
14
+ const holder = doc.createElement("div");
15
+ holder.innerHTML = kept.html;
16
+ const node = holder.firstElementChild;
17
+ if (!node)
18
+ return null;
19
+ for (const el of [node, ...Array.from(node.querySelectorAll("*"))]) {
20
+ for (const name of CLIP_ATTRIBUTES)
21
+ el.removeAttribute(name);
22
+ for (const name of MEDIA_SOURCE_ATTRIBUTES)
23
+ el.removeAttribute(name);
24
+ const remote = Object.entries(getElementStyles(el)).filter(([, v]) => v.includes("url("));
25
+ if (remote.length > 0)
26
+ setElementStyles(el, Object.fromEntries(remote.map(([k]) => [k, null])));
27
+ }
28
+ setElementStyles(node, { display: "none" });
29
+ node.setAttribute("data-hidden", "");
30
+ return node;
31
+ }
32
+ /** Put the hidden copy back where the node was; null when an undo already made it live or its parent is gone. */
33
+ function placeKeptDelete(doc, kept) {
34
+ if (kept.hfId && findById(doc, kept.hfId))
35
+ return null;
36
+ const node = hiddenCopy(doc, kept);
37
+ const parent = kept.parentId ? findById(doc, kept.parentId) : doc.body;
38
+ if (!node || !parent)
39
+ return null;
40
+ parent.insertBefore(node, parent.children[kept.siblingIndex] ?? null);
41
+ return node;
42
+ }
43
+ function withKeptDeletes(parsed, serialize) {
44
+ const placed = (parsed.keptDeletes ?? []).flatMap((kept) => placeKeptDelete(parsed.document, kept) ?? []);
45
+ try {
46
+ return serialize();
47
+ }
48
+ finally {
49
+ for (const node of placed)
50
+ node.remove();
51
+ }
52
+ }
7
53
  /**
8
54
  * Serialize the live document back to HTML.
9
55
  *
@@ -11,9 +57,11 @@
11
57
  * If the original input had a full HTML shell (wrapped=false), returns the full document.
12
58
  */
13
59
  export function serializeDocument(parsed) {
14
- const doc = parsed.document;
15
- if (parsed.wrapped) {
16
- return doc.body.innerHTML ?? "";
17
- }
18
- return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
60
+ return withKeptDeletes(parsed, () => {
61
+ const doc = parsed.document;
62
+ if (parsed.wrapped) {
63
+ return doc.body.innerHTML ?? "";
64
+ }
65
+ return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
66
+ });
19
67
  }
package/dist/session.js CHANGED
@@ -49,6 +49,7 @@ class CompositionImpl {
49
49
  selectionHandlers = [];
50
50
  patchHandlers = [];
51
51
  errorHandlers = [];
52
+ persistErrorUnsubscribes = new Set();
52
53
  previewSelectionUnsubscribe = null;
53
54
  /** Attached by openComposition() for standalone mode. */
54
55
  historyModule = null;
@@ -626,9 +627,12 @@ class CompositionImpl {
626
627
  const typedH = h;
627
628
  this.errorHandlers.push(typedH);
628
629
  const offPersist = this.persist?.on("persist:error", typedH);
630
+ if (offPersist)
631
+ this.persistErrorUnsubscribes.add(offPersist);
629
632
  return () => {
630
633
  this.errorHandlers = this.errorHandlers.filter((x) => x !== typedH);
631
- offPersist?.();
634
+ if (offPersist && this.persistErrorUnsubscribes.delete(offPersist))
635
+ offPersist();
632
636
  };
633
637
  }
634
638
  return () => { };
@@ -679,6 +683,9 @@ class CompositionImpl {
679
683
  dispose() {
680
684
  this.previewSelectionUnsubscribe?.();
681
685
  this.previewSelectionUnsubscribe = null;
686
+ for (const unsubscribe of this.persistErrorUnsubscribes)
687
+ unsubscribe();
688
+ this.persistErrorUnsubscribes.clear();
682
689
  this.persistQueueModule?.dispose();
683
690
  this.historyModule?.dispose();
684
691
  this.changeHandlers = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/sdk",
3
- "version": "0.8.190",
3
+ "version": "0.8.192",
4
4
  "description": "Headless, framework-neutral HyperFrames composition editing engine",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,8 +44,8 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "linkedom": "^0.18.12",
47
- "@hyperframes/parsers": "0.8.190",
48
- "@hyperframes/core": "0.8.190"
47
+ "@hyperframes/core": "0.8.192",
48
+ "@hyperframes/parsers": "0.8.192"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^25.0.10",