@hyperframes/studio 0.6.84 → 0.6.85

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.
package/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-Cw-8psGW.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-DtSCUvYQ.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-DHcptK1_.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.84",
3
+ "version": "0.6.85",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/core": "0.6.84",
35
- "@hyperframes/player": "0.6.84"
34
+ "@hyperframes/core": "0.6.85",
35
+ "@hyperframes/player": "0.6.85"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.84"
49
+ "@hyperframes/producer": "0.6.85"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
@@ -1,13 +1,15 @@
1
1
  /* ── Public constants ──────────────────────────────────────────────── */
2
- export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x";
3
- export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y";
4
- export const STUDIO_WIDTH_PROP = "--hf-studio-width";
5
- export const STUDIO_HEIGHT_PROP = "--hf-studio-height";
2
+ export {
3
+ STUDIO_OFFSET_X_PROP,
4
+ STUDIO_OFFSET_Y_PROP,
5
+ STUDIO_WIDTH_PROP,
6
+ STUDIO_HEIGHT_PROP,
7
+ STUDIO_MANUAL_EDIT_GESTURE_ATTR,
8
+ } from "@hyperframes/core/studio-api/draft-markers";
6
9
  export const STUDIO_ROTATION_PROP = "--hf-studio-rotation";
7
10
 
8
11
  /* ── Internal DOM attribute names ─────────────────────────────────── */
9
12
  export const STUDIO_PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
10
- export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture";
11
13
  export const STUDIO_BOX_SIZE_ATTR = "data-hf-studio-box-size";
12
14
  export const STUDIO_ROTATION_ATTR = "data-hf-studio-rotation";
13
15
  export const STUDIO_ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
@@ -517,17 +517,88 @@ describe("motion attribute round-trip via sourcePatcher", () => {
517
517
  });
518
518
  });
519
519
 
520
- // T3 — id-based targeting (spec for R1).
521
- // R1 adds `hfId?: string` to PatchTarget and a `[data-hf-id="…"]` lookup branch
522
- // in findTagByTarget. Convert from it.todo to real assertions in the R1 PR.
520
+ // T3 — id-based targeting (R1).
523
521
  describe("T3 — hfId targeting (spec for R1)", () => {
524
- it.todo("updates inline style by data-hf-id");
522
+ it("updates inline style by data-hf-id", () => {
523
+ const html = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
524
+ const result = applyPatchByTarget(
525
+ html,
526
+ { hfId: "hf-x7k2" },
527
+ {
528
+ type: "inline-style",
529
+ property: "color",
530
+ value: "blue",
531
+ },
532
+ );
533
+ expect(result).toContain("color: blue");
534
+ expect(result).toContain('data-hf-id="hf-x7k2"');
535
+ });
525
536
 
526
- it.todo("updates text content by data-hf-id");
537
+ it("updates text content by data-hf-id", () => {
538
+ const html = `<p data-hf-id="hf-a1b2">Old text</p>`;
539
+ const result = applyPatchByTarget(
540
+ html,
541
+ { hfId: "hf-a1b2" },
542
+ {
543
+ type: "text-content",
544
+ property: "",
545
+ value: "New text",
546
+ },
547
+ );
548
+ expect(result).toContain(">New text<");
549
+ });
527
550
 
528
- it.todo("updates attribute by data-hf-id");
551
+ it("updates attribute by data-hf-id", () => {
552
+ const html = `<div data-hf-id="hf-c3d4" data-start="0"></div>`;
553
+ const result = applyPatchByTarget(
554
+ html,
555
+ { hfId: "hf-c3d4" },
556
+ {
557
+ type: "attribute",
558
+ property: "start",
559
+ value: "2.5",
560
+ },
561
+ );
562
+ expect(result).toContain('data-start="2.5"');
563
+ });
564
+
565
+ it("data-hf-id attribute is preserved after a style patch", () => {
566
+ const html = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
567
+ const patched = applyPatchByTarget(
568
+ html,
569
+ { hfId: "hf-x7k2" },
570
+ {
571
+ type: "inline-style",
572
+ property: "color",
573
+ value: "blue",
574
+ },
575
+ );
576
+ expect(readAttributeByTarget(patched, { hfId: "hf-x7k2" }, "data-hf-id")).toBe("hf-x7k2");
577
+ });
529
578
 
530
- it.todo("data-hf-id attribute is preserved after a style patch");
579
+ it("hfId lookup falls through to selector when hfId not found", () => {
580
+ const html = `<h1 class="headline" style="color: red">Hello</h1>`;
581
+ const result = applyPatchByTarget(
582
+ html,
583
+ { hfId: "hf-missing", selector: ".headline" },
584
+ { type: "inline-style", property: "color", value: "blue" },
585
+ );
586
+ expect(result).toContain("color: blue");
587
+ });
531
588
 
532
- it.todo("hfId lookup falls through to selector when hfId not found");
589
+ it("hfId match is authoritative selector is not used as a narrowing filter", () => {
590
+ // hfId matches h1; selector points at h2. hfId wins — patch lands on h1, h2 untouched.
591
+ const html = `<h1 data-hf-id="hf-x7k2" class="a">A</h1><h2 class="b">B</h2>`;
592
+ const result = applyPatchByTarget(
593
+ html,
594
+ { hfId: "hf-x7k2", selector: ".b" },
595
+ { type: "inline-style", property: "color", value: "blue" },
596
+ );
597
+ expect(result).toContain('data-hf-id="hf-x7k2"');
598
+ const h1End = result.indexOf("</h1>");
599
+ const bluePos = result.indexOf("color: blue");
600
+ expect(bluePos).toBeGreaterThan(-1);
601
+ expect(bluePos).toBeLessThan(h1End);
602
+ expect(result).toContain('<h2 class="b">B</h2>');
603
+ });
533
604
  });
@@ -94,6 +94,7 @@ export interface PatchOperation {
94
94
 
95
95
  export interface PatchTarget {
96
96
  id?: string | null;
97
+ hfId?: string;
97
98
  selector?: string;
98
99
  selectorIndex?: number;
99
100
  }
@@ -232,61 +233,67 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin
232
233
  return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
233
234
  }
234
235
 
235
- export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
236
- if (target.id) {
237
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(target.id)}\\2[^>]*)>`, "i");
238
- const match = idPattern.exec(html);
239
- if (match?.index != null) {
236
+ function execDataAttrPattern(html: string, attr: string, value: string): TagMatch | null {
237
+ const pattern = new RegExp(`(<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\2[^>]*)>`, "i");
238
+ const match = pattern.exec(html);
239
+ if (match?.index == null) return null;
240
+ // Defensive: a second exact match means a duplicate id/attr in the source
241
+ // (id drift). Don't silently patch the first while leaving the other stale —
242
+ // surface it. By the mint contract this should never fire.
243
+ const all = html.match(new RegExp(`<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\1[^>]*>`, "gi"));
244
+ if (all && all.length > 1) {
245
+ // eslint-disable-next-line no-console
246
+ console.warn(
247
+ `sourcePatcher: ${attr}="${value}" matched ${all.length} elements; patching the first. ids/attrs must be unique per document.`,
248
+ );
249
+ }
250
+ return { tag: match[1], start: match.index, end: match.index + match[1].length };
251
+ }
252
+
253
+ function findTagByClass(html: string, target: PatchTarget): TagMatch | null {
254
+ const classMatch = target.selector?.match(/^\.([a-zA-Z0-9_-]+)$/);
255
+ if (!classMatch) return null;
256
+ const cls = classMatch[1];
257
+ const pattern = new RegExp(
258
+ `(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
259
+ "gi",
260
+ );
261
+ const selectorIndex = target.selectorIndex ?? 0;
262
+ let match: RegExpExecArray | null;
263
+ let currentIndex = 0;
264
+ while ((match = pattern.exec(html)) !== null) {
265
+ if (currentIndex === selectorIndex && match.index != null) {
240
266
  return {
241
267
  tag: match[1],
242
268
  start: match.index,
243
269
  end: match.index + match[1].length,
244
270
  };
245
271
  }
272
+ currentIndex += 1;
273
+ }
274
+ return null;
275
+ }
276
+
277
+ export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
278
+ if (target.hfId) {
279
+ const result = execDataAttrPattern(html, "data-hf-id", target.hfId);
280
+ if (result) return result;
281
+ }
282
+
283
+ if (target.id) {
284
+ const result = execDataAttrPattern(html, "id", target.id);
285
+ if (result) return result;
246
286
  }
247
287
 
248
288
  if (!target.selector) return null;
249
289
 
250
290
  const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
251
291
  if (compositionIdMatch) {
252
- const compId = compositionIdMatch[1];
253
- const pattern = new RegExp(
254
- `(<[^>]*\\bdata-composition-id=(["'])${escapeRegex(compId)}\\2[^>]*)>`,
255
- "i",
256
- );
257
- const match = pattern.exec(html);
258
- if (match?.index != null) {
259
- return {
260
- tag: match[1],
261
- start: match.index,
262
- end: match.index + match[1].length,
263
- };
264
- }
292
+ const result = execDataAttrPattern(html, "data-composition-id", compositionIdMatch[1]);
293
+ if (result) return result;
265
294
  }
266
295
 
267
- const classMatch = target.selector.match(/^\.([a-zA-Z0-9_-]+)$/);
268
- if (classMatch) {
269
- const cls = classMatch[1];
270
- const pattern = new RegExp(
271
- `(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
272
- "gi",
273
- );
274
- const selectorIndex = target.selectorIndex ?? 0;
275
- let match: RegExpExecArray | null;
276
- let currentIndex = 0;
277
- while ((match = pattern.exec(html)) !== null) {
278
- if (currentIndex === selectorIndex && match.index != null) {
279
- return {
280
- tag: match[1],
281
- start: match.index,
282
- end: match.index + match[1].length,
283
- };
284
- }
285
- currentIndex += 1;
286
- }
287
- }
288
-
289
- return null;
296
+ return findTagByClass(html, target);
290
297
  }
291
298
 
292
299
  export function readAttributeByTarget(