@hyperframes/studio 0.8.28 → 0.8.30
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-Bldv3Gqv.js → hyperframes-player-jDyYPwpC.js} +1 -1
- package/dist/assets/{index-ZfDXlqBQ.js → index-BLvqFdnB.js} +1 -1
- package/dist/assets/{index-B50A8gg2.js → index-DRWtJgb8.js} +1 -1
- package/dist/assets/{index-O3ilbgfR.js → index-hQAWl6re.js} +137 -137
- package/dist/index.html +1 -1
- package/dist/index.js +61 -36
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/colorGradingScopePatch.test.ts +20 -0
- package/src/components/editor/colorGradingScopePatch.ts +1 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +41 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +60 -35
- package/src/utils/htmlEditor.test.ts +42 -0
- package/src/utils/htmlEditor.ts +4 -4
- package/src/utils/sourcePatcher.test.ts +33 -0
- package/src/utils/sourcePatcher.ts +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.30",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/sdk": "0.8.
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
55
|
-
"@hyperframes/parsers": "0.8.
|
|
51
|
+
"@hyperframes/player": "0.8.30",
|
|
52
|
+
"@hyperframes/core": "0.8.30",
|
|
53
|
+
"@hyperframes/sdk": "0.8.30",
|
|
54
|
+
"@hyperframes/studio-server": "0.8.30",
|
|
55
|
+
"@hyperframes/parsers": "0.8.30"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
70
|
"vitest": "^3.2.4",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.30"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
|
@@ -2,6 +2,26 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { patchMediaColorGradingInHtml } from "./colorGradingScopePatch";
|
|
3
3
|
|
|
4
4
|
describe("patchMediaColorGradingInHtml", () => {
|
|
5
|
+
it.each(['"', "'"])("replaces and removes long grading values quoted with %s", (quote) => {
|
|
6
|
+
const otherQuote = quote === '"' ? "'" : '"';
|
|
7
|
+
for (const value of [
|
|
8
|
+
"",
|
|
9
|
+
"line1\nline2",
|
|
10
|
+
"a".repeat(100_000),
|
|
11
|
+
`data-color-grading=${otherQuote}a`.repeat(10_000),
|
|
12
|
+
]) {
|
|
13
|
+
const html = `<img alt="kept"\tDATA-COLOR-GRADING=${quote}${value}${quote} />`;
|
|
14
|
+
expect(patchMediaColorGradingInHtml(html, "new")).toEqual({
|
|
15
|
+
html: '<img alt="kept" data-color-grading="new" />',
|
|
16
|
+
count: 1,
|
|
17
|
+
});
|
|
18
|
+
expect(patchMediaColorGradingInHtml(html, null)).toEqual({
|
|
19
|
+
html: '<img alt="kept" />',
|
|
20
|
+
count: 1,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
5
25
|
it("adds color grading to video and image tags only", () => {
|
|
6
26
|
const { html, count } = patchMediaColorGradingInHtml(
|
|
7
27
|
`<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const MEDIA_TAG_RE = /<\s*(video|img)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
2
|
-
const COLOR_GRADING_ATTR_RE = /\sdata-color-grading=(["'
|
|
2
|
+
const COLOR_GRADING_ATTR_RE = /\sdata-color-grading=(?:"[^"]*"|'[^']*')/i;
|
|
3
3
|
const IGNORED_HTML_RANGE_RE = /<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>/gi;
|
|
4
4
|
|
|
5
5
|
interface TextRange {
|
|
@@ -175,7 +175,7 @@ describe("external file change coordinator", () => {
|
|
|
175
175
|
expect(options.reloadSdkSession).toHaveBeenCalledOnce();
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it("
|
|
178
|
+
it("serializes drains and processes stashed events", async () => {
|
|
179
179
|
const drains: Array<(result: { status: "clean" }) => void> = [];
|
|
180
180
|
const { options } = await mountCoordinator({
|
|
181
181
|
drainPendingChanges: () => new Promise((resolve) => drains.push(resolve)),
|
|
@@ -184,11 +184,14 @@ describe("external file change coordinator", () => {
|
|
|
184
184
|
handler?.({ path: "index.html", content: "first", version: "v2" });
|
|
185
185
|
handler?.({ path: "index.html", content: "second", version: "v3" });
|
|
186
186
|
});
|
|
187
|
+
expect(drains).toHaveLength(1);
|
|
187
188
|
await act(async () => drains[0]?.({ status: "clean" }));
|
|
188
|
-
expect(options.reloadPreview).not.toHaveBeenCalled();
|
|
189
|
-
await act(async () => drains[1]?.({ status: "clean" }));
|
|
190
189
|
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
|
191
|
-
|
|
190
|
+
await act(async () => {});
|
|
191
|
+
expect(drains).toHaveLength(2);
|
|
192
|
+
await act(async () => drains[1]?.({ status: "clean" }));
|
|
193
|
+
expect(options.reloadPreview).toHaveBeenCalledTimes(2);
|
|
194
|
+
expect(options.reloadSdkSession).toHaveBeenCalledTimes(2);
|
|
192
195
|
});
|
|
193
196
|
|
|
194
197
|
it("restores a durable unresolved conflict after remount", async () => {
|
|
@@ -285,4 +288,38 @@ describe("external file change coordinator", () => {
|
|
|
285
288
|
);
|
|
286
289
|
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
287
290
|
});
|
|
291
|
+
|
|
292
|
+
it("completes a reload after a burst of rapid external writes", async () => {
|
|
293
|
+
const drains: Array<(result: { status: "clean" }) => void> = [];
|
|
294
|
+
const reloadPreview = vi.fn();
|
|
295
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
296
|
+
await mountCoordinator({
|
|
297
|
+
drainPendingChanges: vi.fn(
|
|
298
|
+
() => new Promise<{ status: "clean" }>((resolve) => drains.push(resolve)),
|
|
299
|
+
),
|
|
300
|
+
reloadPreview,
|
|
301
|
+
onAcceptedPersistedFileChange,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Fire three events in rapid succession (simulates generator + check + snapshot)
|
|
305
|
+
act(() => {
|
|
306
|
+
handler?.({ path: "index.html", content: "write-1", version: "v1" });
|
|
307
|
+
handler?.({ path: "index.html", content: "write-2", version: "v2" });
|
|
308
|
+
handler?.({ path: "index.html", content: "write-3", version: "v3" });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Only one drain runs — events 2 and 3 are stashed (last one wins)
|
|
312
|
+
expect(drains).toHaveLength(1);
|
|
313
|
+
|
|
314
|
+
// Complete the first drain — triggers reload, then stashed event starts a second drain
|
|
315
|
+
await act(async () => drains[0]?.({ status: "clean" }));
|
|
316
|
+
expect(reloadPreview).toHaveBeenCalledOnce();
|
|
317
|
+
await act(async () => {});
|
|
318
|
+
expect(drains).toHaveLength(2);
|
|
319
|
+
|
|
320
|
+
// Complete the second drain — processes the final write
|
|
321
|
+
await act(async () => drains[1]?.({ status: "clean" }));
|
|
322
|
+
expect(reloadPreview).toHaveBeenCalledTimes(2);
|
|
323
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
|
|
324
|
+
});
|
|
288
325
|
});
|
|
@@ -135,6 +135,8 @@ export function useExternalFileChangeCoordinator({
|
|
|
135
135
|
const lastEventIdentityRef = useRef<string | null>(null);
|
|
136
136
|
const blockedRef = useRef(blocked);
|
|
137
137
|
const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
|
|
138
|
+
const drainingRef = useRef(false);
|
|
139
|
+
const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
|
|
138
140
|
blockedRef.current = blocked;
|
|
139
141
|
|
|
140
142
|
useEffect(() => {
|
|
@@ -213,37 +215,11 @@ export function useExternalFileChangeCoordinator({
|
|
|
213
215
|
await next;
|
|
214
216
|
}, []);
|
|
215
217
|
|
|
216
|
-
const
|
|
218
|
+
const drainOnePending = useCallback(
|
|
217
219
|
// fallow-ignore-next-line complexity
|
|
218
|
-
async (payload: unknown
|
|
220
|
+
async (payload: unknown) => {
|
|
219
221
|
const path = readStudioFileChangePath(payload);
|
|
220
|
-
if (!path
|
|
221
|
-
const pendingTimelinePaths = pendingTimelineEditPathRef.current;
|
|
222
|
-
// The old path-only suppression could drop a real agent/user write that
|
|
223
|
-
// raced ahead of the timeline write receipt. Clear the legacy marker but
|
|
224
|
-
// decide ownership only from the exact write token/content below.
|
|
225
|
-
pendingTimelinePaths.delete(path);
|
|
226
|
-
|
|
227
|
-
const content = readFileChangeContent(payload);
|
|
228
|
-
const token = readFileChangeWriteToken(payload);
|
|
229
|
-
logReload("file-change", { path, token: token ?? null, hasContent: content != null });
|
|
230
|
-
const identity = eventIdentity(path, payload);
|
|
231
|
-
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) {
|
|
232
|
-
logReload("suppressed", { path, why: "duplicate event" });
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
lastEventIdentityRef.current = identity;
|
|
236
|
-
|
|
237
|
-
const ownWriteToken = consumeStudioWriteToken(token);
|
|
238
|
-
const ownContentEcho = content != null && isSelfWriteEcho(path, content);
|
|
239
|
-
if (ownWriteToken || ownContentEcho) {
|
|
240
|
-
onAcceptedPersistedFileChange(path);
|
|
241
|
-
logReload("suppressed", {
|
|
242
|
-
path,
|
|
243
|
-
why: ownWriteToken ? "own write token" : "own content echo",
|
|
244
|
-
});
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
222
|
+
if (!path) return;
|
|
247
223
|
|
|
248
224
|
const generation = ++generationRef.current;
|
|
249
225
|
const result = await drainPendingChanges();
|
|
@@ -253,7 +229,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
253
229
|
const previousBlocked = blockedRef.current;
|
|
254
230
|
if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
|
|
255
231
|
try {
|
|
256
|
-
await deleteConflictSnapshot(projectId
|
|
232
|
+
await deleteConflictSnapshot(projectId!, path);
|
|
257
233
|
} catch (error) {
|
|
258
234
|
if (mountedRef.current && generation === generationRef.current) {
|
|
259
235
|
setBlocked({ ...previousBlocked, generation, error });
|
|
@@ -267,6 +243,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
267
243
|
reloadAcceptedGeneration(path);
|
|
268
244
|
return;
|
|
269
245
|
}
|
|
246
|
+
const content = readFileChangeContent(payload);
|
|
270
247
|
if (result.status === "failed") {
|
|
271
248
|
const candidate = getPendingCandidate?.();
|
|
272
249
|
const studioContent = candidate?.path === path ? candidate.content : null;
|
|
@@ -275,7 +252,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
275
252
|
try {
|
|
276
253
|
await persistSnapshotInOrder(() =>
|
|
277
254
|
persistFailureSnapshot(
|
|
278
|
-
projectId
|
|
255
|
+
projectId!,
|
|
279
256
|
path,
|
|
280
257
|
studioContent,
|
|
281
258
|
readFileChangeVersion(payload),
|
|
@@ -305,7 +282,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
305
282
|
return;
|
|
306
283
|
}
|
|
307
284
|
try {
|
|
308
|
-
await persistSnapshotInOrder(() => persistConflictSnapshot(projectId
|
|
285
|
+
await persistSnapshotInOrder(() => persistConflictSnapshot(projectId!, result.error));
|
|
309
286
|
} catch (error) {
|
|
310
287
|
if (!mountedRef.current || generation !== generationRef.current) return;
|
|
311
288
|
setBlocked({
|
|
@@ -323,9 +300,8 @@ export function useExternalFileChangeCoordinator({
|
|
|
323
300
|
setBlocked({ status: "conflict", generation, error: result.error, payload });
|
|
324
301
|
},
|
|
325
302
|
[
|
|
326
|
-
projectId,
|
|
327
|
-
pendingTimelineEditPathRef,
|
|
328
303
|
drainPendingChanges,
|
|
304
|
+
projectId,
|
|
329
305
|
deleteConflictSnapshot,
|
|
330
306
|
getPendingCandidate,
|
|
331
307
|
persistConflictSnapshot,
|
|
@@ -336,6 +312,55 @@ export function useExternalFileChangeCoordinator({
|
|
|
336
312
|
],
|
|
337
313
|
);
|
|
338
314
|
|
|
315
|
+
const startDrainLoop = useCallback(async () => {
|
|
316
|
+
if (drainingRef.current) return;
|
|
317
|
+
drainingRef.current = true;
|
|
318
|
+
try {
|
|
319
|
+
while (mountedRef.current) {
|
|
320
|
+
const pending = pendingPayloadRef.current;
|
|
321
|
+
if (!pending) break;
|
|
322
|
+
pendingPayloadRef.current = null;
|
|
323
|
+
await drainOnePending(pending.payload);
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
drainingRef.current = false;
|
|
327
|
+
}
|
|
328
|
+
}, [drainOnePending]);
|
|
329
|
+
|
|
330
|
+
const processChange = useCallback(
|
|
331
|
+
// fallow-ignore-next-line complexity
|
|
332
|
+
(payload: unknown) => {
|
|
333
|
+
const path = readStudioFileChangePath(payload);
|
|
334
|
+
if (!path || !projectId) return;
|
|
335
|
+
pendingTimelineEditPathRef.current.delete(path);
|
|
336
|
+
|
|
337
|
+
const content = readFileChangeContent(payload);
|
|
338
|
+
const token = readFileChangeWriteToken(payload);
|
|
339
|
+
logReload("file-change", { path, token: token ?? null, hasContent: content != null });
|
|
340
|
+
const identity = eventIdentity(path, payload);
|
|
341
|
+
if (identity != null && identity === lastEventIdentityRef.current) {
|
|
342
|
+
logReload("suppressed", { path, why: "duplicate event" });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
lastEventIdentityRef.current = identity;
|
|
346
|
+
|
|
347
|
+
const ownWriteToken = consumeStudioWriteToken(token);
|
|
348
|
+
const ownContentEcho = content != null && isSelfWriteEcho(path, content);
|
|
349
|
+
if (ownWriteToken || ownContentEcho) {
|
|
350
|
+
onAcceptedPersistedFileChange(path);
|
|
351
|
+
logReload("suppressed", {
|
|
352
|
+
path,
|
|
353
|
+
why: ownWriteToken ? "own write token" : "own content echo",
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
pendingPayloadRef.current = { payload };
|
|
359
|
+
void startDrainLoop();
|
|
360
|
+
},
|
|
361
|
+
[projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
|
|
362
|
+
);
|
|
363
|
+
|
|
339
364
|
useEffect(() => {
|
|
340
365
|
const handler = (payload?: unknown) => processChange(payload);
|
|
341
366
|
const adapter = testHotAdapter();
|
|
@@ -357,7 +382,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
357
382
|
if (!current || current.status === "conflict" || current.recovered) return;
|
|
358
383
|
resetSaveQueues?.();
|
|
359
384
|
lastEventIdentityRef.current = null;
|
|
360
|
-
|
|
385
|
+
processChange(current.payload);
|
|
361
386
|
}, [processChange, resetSaveQueues]);
|
|
362
387
|
|
|
363
388
|
const useExternalFile = useCallback(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { mergeStyleIntoTag } from "./htmlEditor";
|
|
3
|
+
|
|
4
|
+
describe("mergeStyleIntoTag", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['<div style="color: red; opacity: 1">', '<div style="color: red; opacity: 0.5">'],
|
|
7
|
+
["<div style='color: red; opacity: 1'>", "<div style='color: red; opacity: 0.5'>"],
|
|
8
|
+
['<div style="">', '<div style="opacity: 0.5">'],
|
|
9
|
+
["<div style=''>", "<div style='opacity: 0.5'>"],
|
|
10
|
+
['<div style="color:\nred">', '<div style="color: red; opacity: 0.5">'],
|
|
11
|
+
["<div style='font-family: \"a\"'>", "<div style='font-family: \"a\"; opacity: 0.5'>"],
|
|
12
|
+
["<div style=\"font-family: 'a'\">", "<div style=\"font-family: 'a'; opacity: 0.5\">"],
|
|
13
|
+
[
|
|
14
|
+
"<div style=\"color:red\" style='color:blue'>",
|
|
15
|
+
"<div style=\"color: red; opacity: 0.5\" style='color:blue'>",
|
|
16
|
+
],
|
|
17
|
+
['<div STYLE="color:red">', '<div STYLE="color:red" style="opacity: 0.5">'],
|
|
18
|
+
['<div style = "color:red">', '<div style = "color:red" style="opacity: 0.5">'],
|
|
19
|
+
['<div data-style="color:red">', '<div data-style="color: red; opacity: 0.5">'],
|
|
20
|
+
["<img/>", '<img style="opacity: 0.5"/>'],
|
|
21
|
+
['<div style="color:red>', '<div style="color:red style="opacity: 0.5">'],
|
|
22
|
+
])("preserves quote and source behavior (case %#)", (tag, expected) => {
|
|
23
|
+
expect(mergeStyleIntoTag(tag, "opacity: 0.5")).toBe(expected);
|
|
24
|
+
expect(mergeStyleIntoTag(tag, " \n")).toBe(tag);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it.each(['"', "'"])("handles long values delimited by %s", (quote) => {
|
|
28
|
+
const value = "a".repeat(100_000);
|
|
29
|
+
const tag = `<div style=${quote}--label:${value}${quote}>`;
|
|
30
|
+
expect(mergeStyleIntoTag(tag, "opacity: 0.5")).toBe(
|
|
31
|
+
`<div style=${quote}--label: ${value}; opacity: 0.5${quote}>`,
|
|
32
|
+
);
|
|
33
|
+
const opener = `style=${quote}a`;
|
|
34
|
+
expect(mergeStyleIntoTag(`<div style=${quote}${opener.repeat(10_000)}>`, "opacity: 0.5")).toBe(
|
|
35
|
+
`<div style=${quote}opacity: 0.5${quote}a${opener.repeat(9_999)}>`,
|
|
36
|
+
);
|
|
37
|
+
const unterminated = `<div style=${quote}${value}>`;
|
|
38
|
+
expect(mergeStyleIntoTag(unterminated, "opacity: 0.5")).toBe(
|
|
39
|
+
`<div style=${quote}${value} style="opacity: 0.5">`,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/utils/htmlEditor.ts
CHANGED
|
@@ -29,13 +29,13 @@ export function mergeStyleIntoTag(tag: string, newStyles: string): string {
|
|
|
29
29
|
|
|
30
30
|
const incoming = parseStyleString(newStyles);
|
|
31
31
|
|
|
32
|
-
// Match
|
|
33
|
-
const styleAttrRe = /style=(["
|
|
32
|
+
// Match each quote-delimited form explicitly, including multi-line values.
|
|
33
|
+
const styleAttrRe = /style=(")([^"]*)"|style=(')([^']*)'/;
|
|
34
34
|
const match = tag.match(styleAttrRe);
|
|
35
35
|
|
|
36
36
|
if (match) {
|
|
37
|
-
const quote = match[1];
|
|
38
|
-
const existing = parseStyleString(match[2]);
|
|
37
|
+
const quote = match[1] ?? match[3];
|
|
38
|
+
const existing = parseStyleString(match[2] ?? match[4]);
|
|
39
39
|
const merged = { ...existing, ...incoming };
|
|
40
40
|
const serialized = Object.entries(merged)
|
|
41
41
|
.map(([k, v]) => `${k}: ${v}`)
|
|
@@ -8,6 +8,22 @@ import {
|
|
|
8
8
|
} from "./sourcePatcher";
|
|
9
9
|
|
|
10
10
|
describe("applyPatchByTarget", () => {
|
|
11
|
+
it.each(['"', "'"])("patches long and multiline styles quoted with %s", (quote) => {
|
|
12
|
+
const otherQuote = quote === '"' ? "'" : '"';
|
|
13
|
+
const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
|
|
14
|
+
for (const value of [
|
|
15
|
+
"",
|
|
16
|
+
"line1\nline2",
|
|
17
|
+
"a".repeat(100_000),
|
|
18
|
+
`style=${otherQuote}a`.repeat(10_000),
|
|
19
|
+
]) {
|
|
20
|
+
const html = `<img id="hero" class="hero" style=${quote}--label:${value}${quote} />`;
|
|
21
|
+
const expected = `<img id="hero" class="hero" style=${quote}--label: ${value}; opacity: 0.5${quote} />`;
|
|
22
|
+
expect(applyPatch(html, "hero", op)).toBe(expected);
|
|
23
|
+
expect(applyPatchByTarget(html, { selector: ".hero" }, op)).toBe(expected);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
11
27
|
it("updates a composition host by data-composition-id selector", () => {
|
|
12
28
|
const html = `<div data-composition-id="intro" data-start="0" data-track-index="1"></div>`;
|
|
13
29
|
const op: PatchOperation = { type: "attribute", property: "start", value: "2.5" };
|
|
@@ -66,6 +82,23 @@ describe("applyPatchByTarget", () => {
|
|
|
66
82
|
expect(result).not.toContain("/ style");
|
|
67
83
|
});
|
|
68
84
|
|
|
85
|
+
it.each(["", " ", "\t\r\n", "\u00a0\u2028", " ".repeat(100_000)])(
|
|
86
|
+
"preserves self-closing whitespace handling (case %#)",
|
|
87
|
+
(whitespace) => {
|
|
88
|
+
const prefix = '<img id="hero" class="hero"';
|
|
89
|
+
const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
|
|
90
|
+
for (const suffix of ["/", "/\n", "/\r", "/\r\n", "/\u2028", "", "x", "/ "]) {
|
|
91
|
+
const tag = prefix + whitespace + suffix;
|
|
92
|
+
const selfClosing = suffix === "/";
|
|
93
|
+
// The original operation removes whitespace before the slash only.
|
|
94
|
+
const expectedBase = selfClosing ? prefix + suffix.slice(1) : tag;
|
|
95
|
+
const expected = `${expectedBase} style="opacity: 0.5"${selfClosing ? " /" : ""}>`;
|
|
96
|
+
expect(applyPatch(tag + ">", "hero", op)).toBe(expected);
|
|
97
|
+
expect(applyPatchByTarget(tag + ">", { selector: ".hero" }, op)).toBe(expected);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
69
102
|
it("patches inline move styles by target", () => {
|
|
70
103
|
const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
|
|
71
104
|
|
|
@@ -184,10 +184,10 @@ function patchInlineStyleInTag(
|
|
|
184
184
|
if (!tag) return html;
|
|
185
185
|
|
|
186
186
|
// Check if there's an existing style attribute
|
|
187
|
-
const styleMatch = /\bstyle=(["
|
|
187
|
+
const styleMatch = /\bstyle=(")([^"]*)"|\bstyle=(')([^']*)'/.exec(tag);
|
|
188
188
|
if (styleMatch) {
|
|
189
|
-
const existingStyle = styleMatch[2];
|
|
190
|
-
const quote = styleMatch[1];
|
|
189
|
+
const existingStyle = styleMatch[2] ?? styleMatch[4];
|
|
190
|
+
const quote = styleMatch[1] ?? styleMatch[3];
|
|
191
191
|
// Parse existing properties
|
|
192
192
|
const props = new Map<string, string>();
|
|
193
193
|
for (const part of splitInlineStyleDeclarations(existingStyle)) {
|
|
@@ -212,8 +212,8 @@ function patchInlineStyleInTag(
|
|
|
212
212
|
} else {
|
|
213
213
|
// No existing style attribute
|
|
214
214
|
if (value === null) return html; // nothing to remove
|
|
215
|
-
const selfClosing =
|
|
216
|
-
const base = selfClosing ? tag.
|
|
215
|
+
const selfClosing = tag.endsWith("/");
|
|
216
|
+
const base = selfClosing ? tag.slice(0, -1).trimEnd() : tag;
|
|
217
217
|
const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
|
|
218
218
|
return html.replace(tag, newTag);
|
|
219
219
|
}
|