@hyperframes/studio 0.6.114 → 0.6.115
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/assets/hyperframes-player-CNgCuEoe.js +425 -0
- package/dist/assets/{index-DApfviyW.js → index-BIIWBkq6.js} +90 -90
- package/dist/assets/index-BvEhf7SS.css +1 -0
- package/dist/assets/{index-BR5v5xu2.js → index-DjSzIHsE.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/domEditingElement.ts +1 -1
- package/src/components/nle/NLELayout.tsx +1 -1
- package/src/components/panels/SlideshowPanel.tsx +13 -25
- package/src/components/panels/SlideshowSubPanels.tsx +11 -40
- package/src/player/lib/timelineDOM.ts +2 -1
- package/src/player/lib/timelineElementHelpers.ts +5 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -3
- package/src/test-setup.ts +6 -0
- package/src/utils/htmlAttrSafety.ts +90 -0
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +54 -2
- package/src/utils/sdkCutoverParity.test.ts +113 -0
- package/dist/assets/hyperframes-player-C1qkW56w.js +0 -418
- package/dist/assets/index-DS7Kfzvb.css +0 -1
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { trackStudioEvent } from "./studioTelemetry";
|
|
|
8
8
|
import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
|
|
9
9
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
10
10
|
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
|
|
11
|
+
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
|
|
11
12
|
|
|
12
13
|
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
|
|
13
14
|
"inline-style",
|
|
@@ -52,6 +53,54 @@ function mapsToReservedAttr(op: PatchOperation): boolean {
|
|
|
52
53
|
return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// ─── html-attribute safety ───────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
|
|
59
|
+
return ops.some(
|
|
60
|
+
(op) =>
|
|
61
|
+
op.type === "html-attribute" &&
|
|
62
|
+
(!isAllowedHtmlAttribute(op.property) ||
|
|
63
|
+
(op.value !== null && !isSafeAttributeValue(op.property, op.value))),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasTextContentOp(ops: PatchOperation[]): boolean {
|
|
68
|
+
return ops.some((op) => op.type === "text-content");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function targetChildren(target: unknown): unknown[] | null {
|
|
72
|
+
if (!target || typeof target !== "object" || !("children" in target)) return null;
|
|
73
|
+
const children = (target as { children?: unknown }).children;
|
|
74
|
+
return Array.isArray(children) ? children : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function elementTag(element: unknown): string | null {
|
|
78
|
+
if (!element || typeof element !== "object" || !("tag" in element)) return null;
|
|
79
|
+
const tag = (element as { tag?: unknown }).tag;
|
|
80
|
+
return typeof tag === "string" ? tag.toLowerCase() : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
|
|
84
|
+
// Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
|
|
85
|
+
// HTMLElement` — that runtime check catches the same set, but we can't use it
|
|
86
|
+
// here because `target` is a plain SDK object, not a DOM Element. If linkedom
|
|
87
|
+
// (or a future parser) surfaces additional foreign-content elements as
|
|
88
|
+
// non-HTMLElement, add them here.
|
|
89
|
+
const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
|
|
90
|
+
|
|
91
|
+
function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
|
|
92
|
+
if (!hasTextContentOp(ops)) return false;
|
|
93
|
+
const children = targetChildren(target);
|
|
94
|
+
if (!children) return false;
|
|
95
|
+
// Legacy patch-element replaces the whole element for multi-child targets and
|
|
96
|
+
// for single non-HTML children. The SDK text patch stream stores a scalar
|
|
97
|
+
// inverse, so those shapes cannot be made both byte-identical and undo-safe
|
|
98
|
+
// here. Let the server path remain authoritative for them.
|
|
99
|
+
if (children.length > 1) return true;
|
|
100
|
+
const tag = elementTag(children[0]);
|
|
101
|
+
return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
|
|
102
|
+
}
|
|
103
|
+
|
|
55
104
|
export function shouldUseSdkCutover(
|
|
56
105
|
flagEnabled: boolean,
|
|
57
106
|
hasSession: boolean,
|
|
@@ -64,7 +113,8 @@ export function shouldUseSdkCutover(
|
|
|
64
113
|
!!hfId &&
|
|
65
114
|
ops.length > 0 &&
|
|
66
115
|
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
|
|
67
|
-
!ops.some(mapsToReservedAttr)
|
|
116
|
+
!ops.some(mapsToReservedAttr) &&
|
|
117
|
+
!hasUnsafeHtmlAttributeOp(ops)
|
|
68
118
|
);
|
|
69
119
|
}
|
|
70
120
|
|
|
@@ -186,7 +236,9 @@ export async function sdkCutoverPersist(
|
|
|
186
236
|
if (!sdkSession) return false;
|
|
187
237
|
const hfId = selection.hfId;
|
|
188
238
|
if (!hfId) return false;
|
|
189
|
-
|
|
239
|
+
const target = sdkSession.getElement(hfId);
|
|
240
|
+
if (!target) return false;
|
|
241
|
+
if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
|
|
190
242
|
if (wrongCompositionFile(deps, targetPath)) return false;
|
|
191
243
|
try {
|
|
192
244
|
const before = sdkSession.serialize();
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
3
|
+
import { patchElementInHtml } from "../../../core/src/studio-api/helpers/sourceMutation.js";
|
|
4
|
+
import type { PatchOperation } from "./sourcePatcher";
|
|
5
|
+
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
6
|
+
|
|
7
|
+
const shell = (body: string) => `<!DOCTYPE html>
|
|
8
|
+
<html><head></head><body>${body}</body></html>`;
|
|
9
|
+
|
|
10
|
+
async function applySdkDomCutover(source: string, ops: PatchOperation[]): Promise<string> {
|
|
11
|
+
const session = await openComposition(source, { history: false });
|
|
12
|
+
session.batch(() => {
|
|
13
|
+
for (const op of patchOpsToSdkEditOps("hf-target", ops)) {
|
|
14
|
+
session.dispatch(op);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
return session.serialize();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const cases: Array<{ name: string; source: string; ops: PatchOperation[] }> = [
|
|
21
|
+
{
|
|
22
|
+
name: "coalesces multi-property inline style including transform and custom props",
|
|
23
|
+
source: shell(
|
|
24
|
+
'<div data-hf-id="hf-target" style="opacity: 0.5; inset: 0; transform-origin: 0 0">Old<span data-hf-id="hf-child">Child</span></div>',
|
|
25
|
+
),
|
|
26
|
+
ops: [
|
|
27
|
+
{ type: "inline-style", property: "transform", value: "translateX(10px)" },
|
|
28
|
+
{ type: "inline-style", property: "transform-origin", value: "50% 50%" },
|
|
29
|
+
{ type: "inline-style", property: "--x", value: "12px" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "removes one inline style from a multi-property declaration",
|
|
34
|
+
source: shell(
|
|
35
|
+
'<div data-hf-id="hf-target" style="opacity: 0.5; inset: 0; transform-origin: 0 0">Old</div>',
|
|
36
|
+
),
|
|
37
|
+
ops: [{ type: "inline-style", property: "opacity", value: null }],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "preserves semicolon-bearing CSS values when updating another style",
|
|
41
|
+
source: shell(
|
|
42
|
+
'<div data-hf-id="hf-target" style="background: url(data:image/svg+xml;utf8,<svg></svg>); color: red; opacity: 0.5">Old</div>',
|
|
43
|
+
),
|
|
44
|
+
ops: [{ type: "inline-style", property: "color", value: "blue" }],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "sets direct text content",
|
|
48
|
+
source: shell('<div data-hf-id="hf-target">Old</div>'),
|
|
49
|
+
ops: [{ type: "text-content", property: "text", value: "New" }],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "sets text on the single child target used by the legacy path",
|
|
53
|
+
source: shell('<button data-hf-id="hf-target"><span data-hf-id="hf-child">Old</span></button>'),
|
|
54
|
+
ops: [{ type: "text-content", property: "text", value: "New" }],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "sets text on the single child target while preserving parent text",
|
|
58
|
+
source: shell('<div data-hf-id="hf-target">Lead <span data-hf-id="hf-child">Old</span></div>'),
|
|
59
|
+
ops: [{ type: "text-content", property: "text", value: "New" }],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "sets data attributes through attribute ops",
|
|
63
|
+
source: shell('<div data-hf-id="hf-target" data-old="1">Old</div>'),
|
|
64
|
+
ops: [{ type: "attribute", property: "mode", value: "hero" }],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "removes data attributes through attribute ops",
|
|
68
|
+
source: shell('<div data-hf-id="hf-target" data-mode="hero">Old</div>'),
|
|
69
|
+
ops: [{ type: "attribute", property: "mode", value: null }],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "sets allowed html attributes",
|
|
73
|
+
source: shell('<a data-hf-id="hf-target" href="https://example.com">Old</a>'),
|
|
74
|
+
ops: [{ type: "html-attribute", property: "aria-label", value: "Primary link" }],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "sets shorthand over existing longhands (inset over top/right/bottom/left)",
|
|
78
|
+
source: shell(
|
|
79
|
+
'<div data-hf-id="hf-target" style="top: 10px; right: 10px; bottom: 10px; left: 10px; opacity: 1">Old</div>',
|
|
80
|
+
),
|
|
81
|
+
ops: [{ type: "inline-style", property: "inset", value: "0" }],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "sets longhand over existing shorthand (top over inset)",
|
|
85
|
+
source: shell('<div data-hf-id="hf-target" style="inset: 0; opacity: 1">Old</div>'),
|
|
86
|
+
ops: [{ type: "inline-style", property: "top", value: "10px" }],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "mixed-type batch: inline-style + text-content in one op list",
|
|
90
|
+
source: shell('<div data-hf-id="hf-target" style="color: red">Old</div>'),
|
|
91
|
+
ops: [
|
|
92
|
+
{ type: "inline-style", property: "color", value: "blue" },
|
|
93
|
+
{ type: "text-content", property: "text", value: "New" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "mixed-type batch: inline-style + attribute in one op list",
|
|
98
|
+
source: shell('<div data-hf-id="hf-target" style="opacity: 0.5" data-mode="default">Old</div>'),
|
|
99
|
+
ops: [
|
|
100
|
+
{ type: "inline-style", property: "opacity", value: "1" },
|
|
101
|
+
{ type: "attribute", property: "mode", value: "hero" },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
describe("SDK cutover DOM serialization parity", () => {
|
|
107
|
+
it.each(cases)("$name", async ({ source, ops }) => {
|
|
108
|
+
const legacy = patchElementInHtml(source, { hfId: "hf-target" }, ops);
|
|
109
|
+
expect(legacy.matched).toBe(true);
|
|
110
|
+
|
|
111
|
+
await expect(applySdkDomCutover(source, ops)).resolves.toBe(legacy.html);
|
|
112
|
+
});
|
|
113
|
+
});
|