@hyperframes/studio 0.6.80 → 0.6.82
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/index-DHcptK1_.css +1 -0
- package/dist/assets/{index-D8oim9P5.js → index-l2BH41kD.js} +34 -34
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/editor/fontCatalog.ts +12 -5
- package/src/components/editor/manualEditsDomPatches.test.ts +395 -0
- package/src/components/editor/ops.contract.test.ts +39 -0
- package/src/components/editor/propertyPanelFont.tsx +35 -8
- package/src/hooks/useBlockCatalog.ts +10 -12
- package/src/utils/blockCategories.ts +1 -0
- package/src/utils/sourcePatcher.test.ts +15 -0
- package/dist/assets/index-DcyZuBcU.css +0 -1
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-l2BH41kD.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DHcptK1_.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.82",
|
|
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/
|
|
35
|
-
"@hyperframes/
|
|
34
|
+
"@hyperframes/player": "0.6.82",
|
|
35
|
+
"@hyperframes/core": "0.6.82"
|
|
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.
|
|
49
|
+
"@hyperframes/producer": "0.6.82"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "19",
|
|
@@ -111,15 +111,22 @@ export const COMMON_LOCAL_FONT_FAMILIES = [
|
|
|
111
111
|
"SF Pro Text",
|
|
112
112
|
"Avenir",
|
|
113
113
|
"Avenir Next",
|
|
114
|
-
"Helvetica Neue",
|
|
115
|
-
"Arial",
|
|
116
|
-
"Georgia",
|
|
117
|
-
"Times New Roman",
|
|
118
114
|
"Menlo",
|
|
119
115
|
"Monaco",
|
|
120
|
-
"Courier New",
|
|
121
116
|
] as const;
|
|
122
117
|
|
|
118
|
+
import { resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolves the render-time canonical font for a local font family name.
|
|
122
|
+
* Derived from the shared FONT_ALIAS_MAP — no hand-curation needed.
|
|
123
|
+
*/
|
|
124
|
+
export function renderAliasFor(family: string): string | undefined {
|
|
125
|
+
const display = resolveAliasDisplayName(family);
|
|
126
|
+
if (!display || display.toLowerCase() === family.toLowerCase()) return undefined;
|
|
127
|
+
return display;
|
|
128
|
+
}
|
|
129
|
+
|
|
123
130
|
export function googleFontStylesheetUrl(family: string): string {
|
|
124
131
|
const encodedFamily = encodeURIComponent(family.trim()).replace(/%20/g, "+");
|
|
125
132
|
return `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@300;400;500;600;700;800;900&display=swap`;
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
5
|
+
import {
|
|
6
|
+
STUDIO_OFFSET_X_PROP,
|
|
7
|
+
STUDIO_OFFSET_Y_PROP,
|
|
8
|
+
STUDIO_WIDTH_PROP,
|
|
9
|
+
STUDIO_HEIGHT_PROP,
|
|
10
|
+
STUDIO_ROTATION_PROP,
|
|
11
|
+
STUDIO_PATH_OFFSET_ATTR,
|
|
12
|
+
STUDIO_BOX_SIZE_ATTR,
|
|
13
|
+
STUDIO_ROTATION_ATTR,
|
|
14
|
+
STUDIO_ROTATION_DRAFT_ATTR,
|
|
15
|
+
STUDIO_ORIGINAL_TRANSLATE_ATTR,
|
|
16
|
+
STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
|
|
17
|
+
STUDIO_ORIGINAL_WIDTH_ATTR,
|
|
18
|
+
STUDIO_ORIGINAL_HEIGHT_ATTR,
|
|
19
|
+
STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
|
|
20
|
+
STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
|
|
21
|
+
STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
|
|
22
|
+
STUDIO_ORIGINAL_MAX_HEIGHT_ATTR,
|
|
23
|
+
STUDIO_ORIGINAL_FLEX_BASIS_ATTR,
|
|
24
|
+
STUDIO_ORIGINAL_FLEX_GROW_ATTR,
|
|
25
|
+
STUDIO_ORIGINAL_FLEX_SHRINK_ATTR,
|
|
26
|
+
STUDIO_ORIGINAL_BOX_SIZING_ATTR,
|
|
27
|
+
STUDIO_ORIGINAL_SCALE_ATTR,
|
|
28
|
+
STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR,
|
|
29
|
+
STUDIO_ORIGINAL_DISPLAY_ATTR,
|
|
30
|
+
STUDIO_ORIGINAL_ROTATE_ATTR,
|
|
31
|
+
STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
|
|
32
|
+
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
|
33
|
+
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
34
|
+
} from "./manualEditsTypes";
|
|
35
|
+
import {
|
|
36
|
+
STUDIO_MOTION_ATTR,
|
|
37
|
+
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
|
38
|
+
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
|
|
39
|
+
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
|
40
|
+
} from "./studioMotionTypes";
|
|
41
|
+
import {
|
|
42
|
+
buildPathOffsetPatches,
|
|
43
|
+
buildClearPathOffsetPatches,
|
|
44
|
+
buildBoxSizePatches,
|
|
45
|
+
buildClearBoxSizePatches,
|
|
46
|
+
buildRotationPatches,
|
|
47
|
+
buildClearRotationPatches,
|
|
48
|
+
buildMotionPatches,
|
|
49
|
+
buildClearMotionPatches,
|
|
50
|
+
} from "./manualEditsDomPatches";
|
|
51
|
+
|
|
52
|
+
/* ── helpers ── */
|
|
53
|
+
|
|
54
|
+
function div(): HTMLElement {
|
|
55
|
+
return document.createElement("div");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function opKey(op: PatchOperation): string {
|
|
59
|
+
return `${op.type}:${op.property}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertClearCoversKeys(buildOps: PatchOperation[], clearOps: PatchOperation[]): void {
|
|
63
|
+
const clearKeys = new Set(clearOps.map(opKey));
|
|
64
|
+
for (const op of buildOps) {
|
|
65
|
+
expect(clearKeys.has(opKey(op)), `clear missing key "${opKey(op)}"`).toBe(true);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ── Path offset ─────────────────────────────────────────────────────────── */
|
|
70
|
+
|
|
71
|
+
describe("buildPathOffsetPatches / buildClearPathOffsetPatches", () => {
|
|
72
|
+
function populatedPathEl(): HTMLElement {
|
|
73
|
+
const e = div();
|
|
74
|
+
e.style.setProperty(STUDIO_OFFSET_X_PROP, "10px");
|
|
75
|
+
e.style.setProperty(STUDIO_OFFSET_Y_PROP, "20px");
|
|
76
|
+
e.style.setProperty("translate", "10px 20px");
|
|
77
|
+
e.setAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR, "5px 10px");
|
|
78
|
+
e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, "3px");
|
|
79
|
+
e.style.setProperty("display", "flex");
|
|
80
|
+
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "block");
|
|
81
|
+
return e;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it("populated: captures offset styles, attrs, display, and transform-display marker in declaration order", () => {
|
|
85
|
+
const ops = buildPathOffsetPatches(populatedPathEl());
|
|
86
|
+
expect(ops).toEqual([
|
|
87
|
+
{ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: "10px" },
|
|
88
|
+
{ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: "20px" },
|
|
89
|
+
{ type: "inline-style", property: "translate", value: "10px 20px" },
|
|
90
|
+
{ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" },
|
|
91
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: "5px 10px" },
|
|
92
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: "3px" },
|
|
93
|
+
{ type: "inline-style", property: "display", value: "flex" },
|
|
94
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "block" },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("empty: bare element yields only the path-offset marker", () => {
|
|
99
|
+
expect(buildPathOffsetPatches(div())).toEqual([
|
|
100
|
+
{ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("clear: restores translate from STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR and display from STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR", () => {
|
|
105
|
+
const e = div();
|
|
106
|
+
e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, "5px");
|
|
107
|
+
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "grid");
|
|
108
|
+
const ops = buildClearPathOffsetPatches(e);
|
|
109
|
+
expect(ops).toEqual([
|
|
110
|
+
{ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null },
|
|
111
|
+
{ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null },
|
|
112
|
+
{ type: "inline-style", property: "translate", value: "5px" },
|
|
113
|
+
{ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null },
|
|
114
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null },
|
|
115
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null },
|
|
116
|
+
{ type: "inline-style", property: "display", value: "grid" },
|
|
117
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("clear: empty STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR coerces to null (translate not set to empty string)", () => {
|
|
122
|
+
const e = div();
|
|
123
|
+
e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, "");
|
|
124
|
+
const ops = buildClearPathOffsetPatches(e);
|
|
125
|
+
expect(ops.find((o) => o.property === "translate")?.value).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
|
|
129
|
+
const e = populatedPathEl();
|
|
130
|
+
assertClearCoversKeys(buildPathOffsetPatches(e), buildClearPathOffsetPatches(e));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/* ── Box size ────────────────────────────────────────────────────────────── */
|
|
135
|
+
|
|
136
|
+
describe("buildBoxSizePatches / buildClearBoxSizePatches", () => {
|
|
137
|
+
function populatedBoxEl(): HTMLElement {
|
|
138
|
+
const e = div();
|
|
139
|
+
e.style.setProperty(STUDIO_WIDTH_PROP, "300px");
|
|
140
|
+
e.style.setProperty(STUDIO_HEIGHT_PROP, "200px");
|
|
141
|
+
e.style.setProperty("width", "300px");
|
|
142
|
+
e.style.setProperty("height", "200px");
|
|
143
|
+
e.style.setProperty("min-width", "100px");
|
|
144
|
+
e.style.setProperty("min-height", "50px");
|
|
145
|
+
e.style.setProperty("max-width", "500px");
|
|
146
|
+
e.style.setProperty("max-height", "400px");
|
|
147
|
+
e.style.setProperty("flex-basis", "auto");
|
|
148
|
+
e.style.setProperty("flex-grow", "1");
|
|
149
|
+
e.style.setProperty("flex-shrink", "0");
|
|
150
|
+
e.style.setProperty("box-sizing", "border-box");
|
|
151
|
+
e.style.setProperty("scale", "1.5");
|
|
152
|
+
e.style.setProperty("transform-origin", "center");
|
|
153
|
+
e.style.setProperty("display", "block");
|
|
154
|
+
e.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, "250px");
|
|
155
|
+
e.setAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR, "150px");
|
|
156
|
+
e.setAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "0px");
|
|
157
|
+
e.setAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "0px");
|
|
158
|
+
e.setAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "none");
|
|
159
|
+
e.setAttribute(STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "none");
|
|
160
|
+
e.setAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "0px");
|
|
161
|
+
e.setAttribute(STUDIO_ORIGINAL_FLEX_GROW_ATTR, "0");
|
|
162
|
+
e.setAttribute(STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "1");
|
|
163
|
+
e.setAttribute(STUDIO_ORIGINAL_BOX_SIZING_ATTR, "content-box");
|
|
164
|
+
e.setAttribute(STUDIO_ORIGINAL_SCALE_ATTR, "1");
|
|
165
|
+
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "50% 50%");
|
|
166
|
+
e.setAttribute(STUDIO_ORIGINAL_DISPLAY_ATTR, "flex");
|
|
167
|
+
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "");
|
|
168
|
+
return e;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
it("populated: captures studio-width/height, all BOX_SIZE_STYLE_PROPS, marker, and all orig attrs", () => {
|
|
172
|
+
const ops = buildBoxSizePatches(populatedBoxEl());
|
|
173
|
+
expect(ops).toEqual([
|
|
174
|
+
{ type: "inline-style", property: STUDIO_WIDTH_PROP, value: "300px" },
|
|
175
|
+
{ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: "200px" },
|
|
176
|
+
{ type: "inline-style", property: "width", value: "300px" },
|
|
177
|
+
{ type: "inline-style", property: "height", value: "200px" },
|
|
178
|
+
{ type: "inline-style", property: "min-width", value: "100px" },
|
|
179
|
+
{ type: "inline-style", property: "min-height", value: "50px" },
|
|
180
|
+
{ type: "inline-style", property: "max-width", value: "500px" },
|
|
181
|
+
{ type: "inline-style", property: "max-height", value: "400px" },
|
|
182
|
+
{ type: "inline-style", property: "flex-basis", value: "auto" },
|
|
183
|
+
{ type: "inline-style", property: "flex-grow", value: "1" },
|
|
184
|
+
{ type: "inline-style", property: "flex-shrink", value: "0" },
|
|
185
|
+
{ type: "inline-style", property: "box-sizing", value: "border-box" },
|
|
186
|
+
{ type: "inline-style", property: "scale", value: "1.5" },
|
|
187
|
+
{ type: "inline-style", property: "transform-origin", value: "center" },
|
|
188
|
+
{ type: "inline-style", property: "display", value: "block" },
|
|
189
|
+
{ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" },
|
|
190
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: "250px" },
|
|
191
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: "150px" },
|
|
192
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: "0px" },
|
|
193
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, value: "0px" },
|
|
194
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: "none" },
|
|
195
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, value: "none" },
|
|
196
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR, value: "0px" },
|
|
197
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: "0" },
|
|
198
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, value: "1" },
|
|
199
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_BOX_SIZING_ATTR, value: "content-box" },
|
|
200
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: "1" },
|
|
201
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, value: "50% 50%" },
|
|
202
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: "flex" },
|
|
203
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "" },
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("empty: bare element yields only the box-size marker", () => {
|
|
208
|
+
expect(buildBoxSizePatches(div())).toEqual([
|
|
209
|
+
{ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" },
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("clear(populated): ops follow interleaved restore-then-null order for every orig attr", () => {
|
|
214
|
+
const ops = buildClearBoxSizePatches(populatedBoxEl());
|
|
215
|
+
expect(ops).toEqual([
|
|
216
|
+
{ type: "inline-style", property: STUDIO_WIDTH_PROP, value: null },
|
|
217
|
+
{ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null },
|
|
218
|
+
{ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null },
|
|
219
|
+
{ type: "inline-style", property: "width", value: "250px" },
|
|
220
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: null },
|
|
221
|
+
{ type: "inline-style", property: "height", value: "150px" },
|
|
222
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: null },
|
|
223
|
+
{ type: "inline-style", property: "min-width", value: "0px" },
|
|
224
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: null },
|
|
225
|
+
{ type: "inline-style", property: "min-height", value: "0px" },
|
|
226
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, value: null },
|
|
227
|
+
{ type: "inline-style", property: "max-width", value: "none" },
|
|
228
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: null },
|
|
229
|
+
{ type: "inline-style", property: "max-height", value: "none" },
|
|
230
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, value: null },
|
|
231
|
+
{ type: "inline-style", property: "flex-basis", value: "0px" },
|
|
232
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR, value: null },
|
|
233
|
+
{ type: "inline-style", property: "flex-grow", value: "0" },
|
|
234
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: null },
|
|
235
|
+
{ type: "inline-style", property: "flex-shrink", value: "1" },
|
|
236
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, value: null },
|
|
237
|
+
{ type: "inline-style", property: "box-sizing", value: "content-box" },
|
|
238
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_BOX_SIZING_ATTR, value: null },
|
|
239
|
+
{ type: "inline-style", property: "scale", value: "1" },
|
|
240
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: null },
|
|
241
|
+
{ type: "inline-style", property: "transform-origin", value: "50% 50%" },
|
|
242
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, value: null },
|
|
243
|
+
{ type: "inline-style", property: "display", value: "flex" },
|
|
244
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: null },
|
|
245
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null },
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("clear: empty orig attr coerces to null (style is removed rather than set to empty string)", () => {
|
|
250
|
+
const e = div();
|
|
251
|
+
e.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, "");
|
|
252
|
+
const ops = buildClearBoxSizePatches(e);
|
|
253
|
+
expect(ops.find((o) => o.property === "width")?.value).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("clear: bare element emits only null ops — no style restores fire when orig attrs are absent", () => {
|
|
257
|
+
const ops = buildClearBoxSizePatches(div());
|
|
258
|
+
// 3 fixed (studio-width, studio-height, box-size marker) + 14 attr-null pushes (one per BOX_SIZE_ORIG_ATTR)
|
|
259
|
+
expect(ops).toHaveLength(17);
|
|
260
|
+
expect(ops.every((op) => op.value === null)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
|
|
264
|
+
const e = populatedBoxEl();
|
|
265
|
+
assertClearCoversKeys(buildBoxSizePatches(e), buildClearBoxSizePatches(e));
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/* ── Rotation ────────────────────────────────────────────────────────────── */
|
|
270
|
+
|
|
271
|
+
describe("buildRotationPatches / buildClearRotationPatches", () => {
|
|
272
|
+
function populatedRotEl(): HTMLElement {
|
|
273
|
+
const e = div();
|
|
274
|
+
e.style.setProperty(STUDIO_ROTATION_PROP, "45");
|
|
275
|
+
e.style.setProperty("rotate", "45deg");
|
|
276
|
+
e.style.setProperty("transform-origin", "left center");
|
|
277
|
+
e.style.setProperty("display", "block");
|
|
278
|
+
e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg");
|
|
279
|
+
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg");
|
|
280
|
+
e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center");
|
|
281
|
+
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex");
|
|
282
|
+
return e;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => {
|
|
286
|
+
const ops = buildRotationPatches(populatedRotEl());
|
|
287
|
+
expect(ops).toEqual([
|
|
288
|
+
{ type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" },
|
|
289
|
+
{ type: "inline-style", property: "rotate", value: "45deg" },
|
|
290
|
+
{ type: "inline-style", property: "transform-origin", value: "left center" },
|
|
291
|
+
{ type: "inline-style", property: "display", value: "block" },
|
|
292
|
+
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
|
|
293
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" },
|
|
294
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" },
|
|
295
|
+
{
|
|
296
|
+
type: "attribute",
|
|
297
|
+
property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
|
298
|
+
value: "center center",
|
|
299
|
+
},
|
|
300
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" },
|
|
301
|
+
]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("empty: bare element yields only the rotation marker", () => {
|
|
305
|
+
expect(buildRotationPatches(div())).toEqual([
|
|
306
|
+
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
|
|
307
|
+
]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => {
|
|
311
|
+
const e = div();
|
|
312
|
+
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg");
|
|
313
|
+
e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "top left");
|
|
314
|
+
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "grid");
|
|
315
|
+
const ops = buildClearRotationPatches(e);
|
|
316
|
+
expect(ops).toEqual([
|
|
317
|
+
{ type: "inline-style", property: STUDIO_ROTATION_PROP, value: null },
|
|
318
|
+
{ type: "inline-style", property: "rotate", value: "30deg" },
|
|
319
|
+
{ type: "inline-style", property: "transform-origin", value: "top left" },
|
|
320
|
+
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: null },
|
|
321
|
+
{ type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null },
|
|
322
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null },
|
|
323
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null },
|
|
324
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null },
|
|
325
|
+
{ type: "inline-style", property: "display", value: "grid" },
|
|
326
|
+
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null },
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("clear: absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR yields null for transform-origin", () => {
|
|
331
|
+
const ops = buildClearRotationPatches(div());
|
|
332
|
+
expect(ops.find((o) => o.property === "transform-origin")?.value).toBeNull();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("clear: empty STUDIO_ORIGINAL_INLINE_ROTATE_ATTR coerces to null (rotate not set to empty string)", () => {
|
|
336
|
+
const e = div();
|
|
337
|
+
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "");
|
|
338
|
+
const ops = buildClearRotationPatches(e);
|
|
339
|
+
expect(ops.find((o) => o.property === "rotate")?.value).toBeNull();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
|
|
343
|
+
const e = populatedRotEl();
|
|
344
|
+
assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
/* ── Motion ──────────────────────────────────────────────────────────────── */
|
|
349
|
+
|
|
350
|
+
describe("buildMotionPatches / buildClearMotionPatches", () => {
|
|
351
|
+
const MOTION_JSON = '{"kind":"gsap-motion","start":0,"duration":1}';
|
|
352
|
+
|
|
353
|
+
function populatedMotionEl(): HTMLElement {
|
|
354
|
+
const e = div();
|
|
355
|
+
e.setAttribute(STUDIO_MOTION_ATTR, MOTION_JSON);
|
|
356
|
+
e.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, "translateX(0)");
|
|
357
|
+
e.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, "1");
|
|
358
|
+
e.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, "visible");
|
|
359
|
+
return e;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
it("populated: captures motion JSON and all three original attrs when motion attr is present", () => {
|
|
363
|
+
const ops = buildMotionPatches(populatedMotionEl());
|
|
364
|
+
expect(ops).toEqual([
|
|
365
|
+
{ type: "attribute", property: STUDIO_MOTION_ATTR, value: MOTION_JSON },
|
|
366
|
+
{
|
|
367
|
+
type: "attribute",
|
|
368
|
+
property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
|
369
|
+
value: "translateX(0)",
|
|
370
|
+
},
|
|
371
|
+
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: "1" },
|
|
372
|
+
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: "visible" },
|
|
373
|
+
]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("empty: returns [] when STUDIO_MOTION_ATTR is absent", () => {
|
|
377
|
+
expect(buildMotionPatches(div())).toEqual([]);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("clear: always nulls all four motion attrs regardless of element state", () => {
|
|
381
|
+
const expected = [
|
|
382
|
+
{ type: "attribute", property: STUDIO_MOTION_ATTR, value: null },
|
|
383
|
+
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null },
|
|
384
|
+
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null },
|
|
385
|
+
{ type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null },
|
|
386
|
+
];
|
|
387
|
+
expect(buildClearMotionPatches(div())).toEqual(expected);
|
|
388
|
+
expect(buildClearMotionPatches(populatedMotionEl())).toEqual(expected);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
|
|
392
|
+
const e = populatedMotionEl();
|
|
393
|
+
assertClearCoversKeys(buildMotionPatches(e), buildClearMotionPatches(e));
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T4 — Op contract stubs.
|
|
3
|
+
*
|
|
4
|
+
* These tests define the expected shape of the Studio editor operation (op) dispatch boundary
|
|
5
|
+
* that will be introduced during R5 (op-shape refactor) and R6 (runtime bridge).
|
|
6
|
+
* All are .todo until the dispatch boundary is exposed.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
describe("T4 — op contract: move", () => {
|
|
11
|
+
it.todo("move op has { type: 'move', id, x, y } shape");
|
|
12
|
+
it.todo("move op applied to element produces updated left/top style values");
|
|
13
|
+
it.todo("move op is recorded in edit history with label 'Move layer'");
|
|
14
|
+
it.todo("move op coalesces when dragging (same id, within coalesce window)");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("T4 — op contract: resize", () => {
|
|
18
|
+
it.todo("resize op has { type: 'resize', id, width, height } shape");
|
|
19
|
+
it.todo("resize op applied updates width/height style values");
|
|
20
|
+
it.todo("resize op is recorded in edit history with label 'Resize layer'");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("T4 — op contract: retime", () => {
|
|
24
|
+
it.todo("retime op has { type: 'retime', id, startTime, duration } shape");
|
|
25
|
+
it.todo("retime op updates data-start/data-end attributes on the element");
|
|
26
|
+
it.todo("retime op is recorded in edit history with label 'Retime layer'");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("T4 — op contract: style", () => {
|
|
30
|
+
it.todo("style op has { type: 'style', id, prop, value } shape");
|
|
31
|
+
it.todo("style op applied updates the correct inline style property");
|
|
32
|
+
it.todo("style op coalesces same id+prop edits within window");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("T4 — op contract: dispatch boundary", () => {
|
|
36
|
+
it.todo("dispatch emits origin:'studio' on every op for SDK origin guard");
|
|
37
|
+
it.todo("applyPatches with origin:'applyPatches' does not push to undo stack");
|
|
38
|
+
it.todo("dispatching an unknown op type throws at the boundary, not silently fails");
|
|
39
|
+
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
googleFontStylesheetUrl,
|
|
4
|
+
POPULAR_GOOGLE_FONT_FAMILIES,
|
|
5
|
+
renderAliasFor,
|
|
6
|
+
} from "./fontCatalog";
|
|
3
7
|
import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
|
|
4
8
|
import {
|
|
5
9
|
DEFAULT_FONT_FAMILIES,
|
|
@@ -315,12 +319,32 @@ export function FontFamilyField({
|
|
|
315
319
|
);
|
|
316
320
|
};
|
|
317
321
|
|
|
322
|
+
const importSystemFont = async (family: string): Promise<ImportedFontAsset | null> => {
|
|
323
|
+
if (!onImportFonts) return null;
|
|
324
|
+
const response = await fetch(`/api/fonts/file?family=${encodeURIComponent(family)}`);
|
|
325
|
+
if (!response.ok) return null;
|
|
326
|
+
const blob = await response.blob();
|
|
327
|
+
const ext = response.headers.get("Content-Disposition")?.match(/\.(\w+)"?$/)?.[1] ?? "ttf";
|
|
328
|
+
const file = new File([blob], `${family}.${ext}`, { type: blob.type || "font/ttf" });
|
|
329
|
+
const imported = await onImportFonts([file]);
|
|
330
|
+
return (
|
|
331
|
+
imported.find((a) => a.family.toLowerCase() === family.toLowerCase()) ?? imported[0] ?? null
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
|
|
318
335
|
const commitFamily = async (option: FontOption) => {
|
|
319
|
-
|
|
336
|
+
const needsImport =
|
|
337
|
+
option.source === "Local" ||
|
|
338
|
+
(option.source === "System" && !GENERIC_FONT_FAMILIES.has(option.family.toLowerCase()));
|
|
339
|
+
|
|
340
|
+
if (needsImport) {
|
|
320
341
|
setImportingFonts(true);
|
|
321
342
|
setFontNotice(null);
|
|
322
343
|
try {
|
|
323
|
-
const imported =
|
|
344
|
+
const imported =
|
|
345
|
+
option.source === "Local"
|
|
346
|
+
? await importLocalFont(option.family)
|
|
347
|
+
: await importSystemFont(option.family);
|
|
324
348
|
if (imported) {
|
|
325
349
|
loadImportedFontStylesheet(imported);
|
|
326
350
|
onCommit(buildFontFamilyValue(imported.family));
|
|
@@ -328,13 +352,9 @@ export function FontFamilyField({
|
|
|
328
352
|
setOpen(false);
|
|
329
353
|
return;
|
|
330
354
|
}
|
|
331
|
-
onCommit(buildFontFamilyValue(option.family));
|
|
332
|
-
setQuery("");
|
|
333
|
-
setOpen(false);
|
|
334
355
|
} finally {
|
|
335
356
|
setImportingFonts(false);
|
|
336
357
|
}
|
|
337
|
-
return;
|
|
338
358
|
}
|
|
339
359
|
if (option.source === "Google") loadGoogleFontStylesheet(option.family);
|
|
340
360
|
const imported = importedFonts.find(
|
|
@@ -440,7 +460,14 @@ export function FontFamilyField({
|
|
|
440
460
|
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
|
|
441
461
|
}`}
|
|
442
462
|
>
|
|
443
|
-
<span className="min-w-0
|
|
463
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
464
|
+
<span className="truncate font-medium">{option.family}</span>
|
|
465
|
+
{renderAliasFor(option.family) && (
|
|
466
|
+
<span className="flex-shrink-0 text-[9px] text-neutral-500">
|
|
467
|
+
→ {renderAliasFor(option.family)}
|
|
468
|
+
</span>
|
|
469
|
+
)}
|
|
470
|
+
</span>
|
|
444
471
|
<span className="flex-shrink-0 text-[9px] uppercase tracking-[0.14em] text-neutral-600">
|
|
445
472
|
{option.source}
|
|
446
473
|
</span>
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo } from "react";
|
|
2
2
|
import type { RegistryItem } from "@hyperframes/core/registry";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
BLOCK_CATEGORIES,
|
|
5
|
+
type BlockCategory,
|
|
6
|
+
resolveBlockCategory,
|
|
7
|
+
} from "../utils/blockCategories";
|
|
4
8
|
|
|
5
9
|
export type CatalogItem = RegistryItem & {
|
|
6
10
|
category: BlockCategory;
|
|
@@ -15,16 +19,6 @@ export function useBlockCatalog() {
|
|
|
15
19
|
|
|
16
20
|
// fallow-ignore-next-line complexity
|
|
17
21
|
useEffect(() => {
|
|
18
|
-
const CATEGORY_ORDER: Record<BlockCategory, number> = {
|
|
19
|
-
captions: 0,
|
|
20
|
-
vfx: 1,
|
|
21
|
-
transitions: 2,
|
|
22
|
-
effects: 3,
|
|
23
|
-
social: 4,
|
|
24
|
-
data: 5,
|
|
25
|
-
scenes: 6,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
22
|
let cancelled = false;
|
|
29
23
|
(async () => {
|
|
30
24
|
try {
|
|
@@ -34,7 +28,11 @@ export function useBlockCatalog() {
|
|
|
34
28
|
if (cancelled) return;
|
|
35
29
|
const items = data
|
|
36
30
|
.map((b) => ({ ...b, category: resolveBlockCategory(b.tags) }))
|
|
37
|
-
.sort((a, b) =>
|
|
31
|
+
.sort((a, b) => {
|
|
32
|
+
const ia = BLOCK_CATEGORIES.findIndex((c) => c.id === a.category);
|
|
33
|
+
const ib = BLOCK_CATEGORIES.findIndex((c) => c.id === b.category);
|
|
34
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
|
35
|
+
});
|
|
38
36
|
setBlocks(items);
|
|
39
37
|
} catch (err) {
|
|
40
38
|
if (cancelled) return;
|
|
@@ -16,6 +16,7 @@ const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }
|
|
|
16
16
|
scenes: { bg: "bg-amber-500/15", text: "text-amber-400", dot: "bg-amber-400" },
|
|
17
17
|
captions: { bg: "bg-cyan-500/15", text: "text-cyan-400", dot: "bg-cyan-400" },
|
|
18
18
|
effects: { bg: "bg-rose-500/15", text: "text-rose-400", dot: "bg-rose-400" },
|
|
19
|
+
"text-effects": { bg: "bg-violet-500/15", text: "text-violet-400", dot: "bg-violet-400" },
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export function getCategoryColors(category: BlockCategory) {
|
|
@@ -516,3 +516,18 @@ describe("motion attribute round-trip via sourcePatcher", () => {
|
|
|
516
516
|
expect(JSON.parse(readBack!)).toEqual(motion);
|
|
517
517
|
});
|
|
518
518
|
});
|
|
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.
|
|
523
|
+
describe("T3 — hfId targeting (spec for R1)", () => {
|
|
524
|
+
it.todo("updates inline style by data-hf-id");
|
|
525
|
+
|
|
526
|
+
it.todo("updates text content by data-hf-id");
|
|
527
|
+
|
|
528
|
+
it.todo("updates attribute by data-hf-id");
|
|
529
|
+
|
|
530
|
+
it.todo("data-hf-id attribute is preserved after a style patch");
|
|
531
|
+
|
|
532
|
+
it.todo("hfId lookup falls through to selector when hfId not found");
|
|
533
|
+
});
|