@hyperframes/studio 0.8.101 → 0.8.102

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.
@@ -9,13 +9,17 @@ import {
9
9
  PreviewReadOnlyProvider,
10
10
  useDomEditSelectionContext,
11
11
  useDomEditSession,
12
+ useDomEditZOrder,
12
13
  usePreviewPersistence,
13
14
  type ConnectedDomEditOverlayProps,
14
15
  type DomEditCapabilities,
15
16
  type DomEditSelection,
17
+ type DomEditZOrder,
16
18
  type UseDomEditSessionParams,
17
19
  type UsePreviewPersistenceParams,
20
+ type ZOrderAction,
18
21
  } from "@hyperframes/studio";
22
+ import { makeSelection } from "./hooks/domSelectionTestHarness";
19
23
 
20
24
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
21
25
 
@@ -66,4 +70,92 @@ describe("DOM editing package exports", () => {
66
70
  expect(canvas?.className).toContain("pointer-events-none");
67
71
  await act(async () => root.unmount());
68
72
  });
73
+
74
+ it("gives a host the canvas menu's z-order: enabled, then one step through the session's commit", async () => {
75
+ const parent = document.createElement("div");
76
+ const back = document.createElement("div");
77
+ back.id = "back";
78
+ const front = document.createElement("div");
79
+ front.id = "front";
80
+ parent.append(back, front);
81
+ document.body.append(parent);
82
+ const commitZ = vi.fn(() => Promise.resolve());
83
+ const session = {
84
+ handleDomZIndexReorderCommit: commitZ,
85
+ } as unknown as Parameters<typeof DomEditProvider>[0]["value"];
86
+ let zOrder: DomEditZOrder | undefined;
87
+ function Probe() {
88
+ zOrder = useDomEditZOrder();
89
+ return null;
90
+ }
91
+ const el = document.createElement("div");
92
+ document.body.append(el);
93
+ const root = createRoot(el);
94
+ await act(async () =>
95
+ root.render(
96
+ <DomEditProvider value={session}>
97
+ <Probe />
98
+ </DomEditProvider>,
99
+ ),
100
+ );
101
+ const sel = makeSelection("Back", back);
102
+ const toFront: ZOrderAction = "bring-to-front";
103
+ expect(zOrder?.enabled(sel, "send-to-back")).toBe(false);
104
+ expect(zOrder?.enabled(sel, toFront)).toBe(true);
105
+ expect(zOrder?.apply(sel, "send-to-back")).toBe(false);
106
+ expect(commitZ).not.toHaveBeenCalled();
107
+ await act(async () => {
108
+ expect(zOrder?.apply(sel, toFront)).toBe(true);
109
+ });
110
+ expect(commitZ).toHaveBeenCalledTimes(1);
111
+ expect(commitZ).toHaveBeenCalledWith(
112
+ expect.arrayContaining([expect.objectContaining({ element: back, id: "back" })]),
113
+ expect.stringContaining(toFront),
114
+ toFront,
115
+ );
116
+ await act(async () => root.unmount());
117
+ parent.remove();
118
+ });
119
+
120
+ it("gives a read-only preview or a detached element no z-order, and never asks the session", async () => {
121
+ const parent = document.createElement("div");
122
+ const back = document.createElement("div");
123
+ back.id = "back";
124
+ parent.append(back, document.createElement("div"));
125
+ document.body.append(parent);
126
+ const commitZ = vi.fn(() => Promise.resolve());
127
+ const session = {
128
+ handleDomZIndexReorderCommit: commitZ,
129
+ } as unknown as Parameters<typeof DomEditProvider>[0]["value"];
130
+ let zOrder: DomEditZOrder | undefined;
131
+ function Probe() {
132
+ zOrder = useDomEditZOrder();
133
+ return null;
134
+ }
135
+ const el = document.body.appendChild(document.createElement("div"));
136
+ const root = createRoot(el);
137
+ const mount = (readOnly: boolean) =>
138
+ act(async () =>
139
+ root.render(
140
+ <PreviewReadOnlyProvider readOnly={readOnly}>
141
+ <DomEditProvider value={session}>
142
+ <Probe />
143
+ </DomEditProvider>
144
+ </PreviewReadOnlyProvider>,
145
+ ),
146
+ );
147
+ const sel = makeSelection("Back", back);
148
+ await mount(true);
149
+ expect(zOrder?.enabled(sel, "bring-to-front")).toBe(false);
150
+ expect(zOrder?.apply(sel, "bring-to-front")).toBe(false);
151
+ expect(zOrder?.commit(sel, [{ element: back, zIndex: 1 }], "bring-to-front", null)).toBe(false);
152
+ await mount(false);
153
+ // A reload leaves the old nodes connected to their old document, one no window shows.
154
+ document.implementation.createHTMLDocument().body.append(parent);
155
+ expect(back.isConnected).toBe(true);
156
+ expect(zOrder?.enabled(sel, "bring-to-front"), "a selection from before a reload").toBe(false);
157
+ expect(zOrder?.apply(sel, "bring-to-front")).toBe(false);
158
+ expect(commitZ).not.toHaveBeenCalled();
159
+ await act(async () => root.unmount());
160
+ });
69
161
  });
@@ -419,6 +419,12 @@ export const POSITION_CHANNELS: string[] = [
419
419
  ];
420
420
  const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"];
421
421
 
422
+ export const GSAP_TRANSFORM_KEYS = new Set(
423
+ "x,y,z,scale,scaleX,scaleY,xPercent,yPercent,rotation,rotationX,rotationY,skewX,skewY,transformOrigin,svgOrigin,force3D,smoothOrigin,transformPerspective,translateX,translateY,translateZ,rotate,rotationZ,rotateZ,rotateX,rotateY".split(
424
+ ",",
425
+ ),
426
+ );
427
+
422
428
  /** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */
423
429
  function gsapWritesChannels(el: Element, channels: string[]): boolean {
424
430
  const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
@@ -42,6 +42,7 @@ interface RenderDomEditCommitsOptions {
42
42
  onTrySdkPersist?: () => Promise<CutoverResult>;
43
43
  queueDomEditSave?: <T>(save: () => Promise<T>) => Promise<T>;
44
44
  projectIdRef?: MutableRefObject<string | null>;
45
+ readOnlyPreview?: boolean;
45
46
  }
46
47
 
47
48
  type FetchHandler = (
@@ -232,7 +233,7 @@ function renderDomEditCommits(
232
233
  target === selection.element ? selection : null,
233
234
  ),
234
235
  onTrySdkPersist: options.onTrySdkPersist,
235
- readOnlyPreview: false,
236
+ readOnlyPreview: options.readOnlyPreview ?? false,
236
237
  });
237
238
  return null;
238
239
  }
@@ -266,97 +267,100 @@ describe("useDomEditCommits z-index reorder persistence", () => {
266
267
  document.body.replaceChildren();
267
268
  });
268
269
 
269
- it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
270
- const original =
271
- '<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
272
- const after =
273
- '<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
274
- const fetchMock = vi.fn(
275
- async (
276
- input: Parameters<typeof fetch>[0],
277
- _init?: Parameters<typeof fetch>[1],
278
- ): Promise<Response> => {
279
- const url = requestUrl(input);
280
- if (url.endsWith("/file-mutations/patch-element-batches")) {
281
- return jsonResponse({
282
- durable: true,
283
- files: [
284
- {
285
- sourceFile: "index.html",
286
- changed: true,
287
- matched: [true, true, true],
288
- before: original,
289
- after,
290
- },
291
- ],
292
- });
293
- }
294
- throw new Error(`Unexpected fetch: ${url}`);
295
- },
296
- );
297
- vi.stubGlobal("fetch", fetchMock);
298
- const { iframe, element } = createPreviewElement(
299
- '<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
300
- );
301
- element.id = "a";
302
- const elements = [
303
- element,
304
- iframe.contentDocument!.getElementById("b")!,
305
- iframe.contentDocument!.getElementById("c")!,
306
- ];
307
- const rendered = renderDomEditCommits(createSelection(element), iframe);
308
-
309
- try {
310
- await act(async () => {
311
- await rendered.hook.handleDomZIndexReorderCommit(
312
- elements.map((item, index) => ({
313
- element: item,
314
- zIndex: 3 - index,
315
- id: item.id,
316
- sourceFile: "index.html",
317
- })),
318
- "z-reorder:test",
319
- );
320
- });
321
-
322
- const batchPosts = fetchMock.mock.calls.filter(([input]) =>
323
- requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
270
+ it.each([false, true])(
271
+ "persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload (read-only preview %s: the timeline's z sync stays editable)",
272
+ async (readOnlyPreview) => {
273
+ const original =
274
+ '<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
275
+ const after =
276
+ '<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
277
+ const fetchMock = vi.fn(
278
+ async (
279
+ input: Parameters<typeof fetch>[0],
280
+ _init?: Parameters<typeof fetch>[1],
281
+ ): Promise<Response> => {
282
+ const url = requestUrl(input);
283
+ if (url.endsWith("/file-mutations/patch-element-batches")) {
284
+ return jsonResponse({
285
+ durable: true,
286
+ files: [
287
+ {
288
+ sourceFile: "index.html",
289
+ changed: true,
290
+ matched: [true, true, true],
291
+ before: original,
292
+ after,
293
+ },
294
+ ],
295
+ });
296
+ }
297
+ throw new Error(`Unexpected fetch: ${url}`);
298
+ },
324
299
  );
325
- const singlePosts = fetchMock.mock.calls.filter(([input]) =>
326
- requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
300
+ vi.stubGlobal("fetch", fetchMock);
301
+ const { iframe, element } = createPreviewElement(
302
+ '<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
327
303
  );
328
- expect(batchPosts).toHaveLength(1);
329
- expect(singlePosts).toHaveLength(0);
330
- expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
331
- batches: [
332
- {
333
- sourceFile: "index.html",
334
- patches: expect.arrayContaining([
335
- expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
336
- expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
337
- expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
338
- ]),
339
- },
340
- ],
341
- });
342
- expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
343
- expect(rendered.recordEdit).toHaveBeenCalledWith({
344
- label: "Reorder layers",
345
- coalesceKey: "z-reorder:test",
346
- // Unbounded per-gesture fold window (keys are unique per gesture):
347
- // the z entry and its mirror/lane counterpart fold across the server
348
- // round-trip that separates them.
349
- coalesceMs: Number.POSITIVE_INFINITY,
350
- files: { "index.html": { before: original, after } },
351
- });
352
- // FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
353
- // The live DOM + store already hold the final state and the server matched
354
- // every style-only patch, so the reload is provably redundant.
355
- expect(rendered.reloadPreview).not.toHaveBeenCalled();
356
- } finally {
357
- rendered.cleanup();
358
- }
359
- });
304
+ element.id = "a";
305
+ const elements = [
306
+ element,
307
+ iframe.contentDocument!.getElementById("b")!,
308
+ iframe.contentDocument!.getElementById("c")!,
309
+ ];
310
+ const rendered = renderDomEditCommits(createSelection(element), iframe, { readOnlyPreview });
311
+
312
+ try {
313
+ await act(async () => {
314
+ await rendered.hook.handleDomZIndexReorderCommit(
315
+ elements.map((item, index) => ({
316
+ element: item,
317
+ zIndex: 3 - index,
318
+ id: item.id,
319
+ sourceFile: "index.html",
320
+ })),
321
+ "z-reorder:test",
322
+ );
323
+ });
324
+
325
+ const batchPosts = fetchMock.mock.calls.filter(([input]) =>
326
+ requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
327
+ );
328
+ const singlePosts = fetchMock.mock.calls.filter(([input]) =>
329
+ requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
330
+ );
331
+ expect(batchPosts).toHaveLength(1);
332
+ expect(singlePosts).toHaveLength(0);
333
+ expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
334
+ batches: [
335
+ {
336
+ sourceFile: "index.html",
337
+ patches: expect.arrayContaining([
338
+ expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
339
+ expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
340
+ expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
341
+ ]),
342
+ },
343
+ ],
344
+ });
345
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
346
+ expect(rendered.recordEdit).toHaveBeenCalledWith({
347
+ label: "Reorder layers",
348
+ coalesceKey: "z-reorder:test",
349
+ // Unbounded per-gesture fold window (keys are unique per gesture):
350
+ // the z entry and its mirror/lane counterpart fold across the server
351
+ // round-trip that separates them.
352
+ coalesceMs: Number.POSITIVE_INFINITY,
353
+ files: { "index.html": { before: original, after } },
354
+ });
355
+ // FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
356
+ // The live DOM + store already hold the final state and the server matched
357
+ // every style-only patch, so the reload is provably redundant.
358
+ expect(rendered.reloadPreview).not.toHaveBeenCalled();
359
+ } finally {
360
+ rendered.cleanup();
361
+ }
362
+ },
363
+ );
360
364
 
361
365
  it("falls back to reloading when the server response omits matched[]", async () => {
362
366
  // Without a matched[] confirmation the persist can't be proven in sync with
package/src/index.ts CHANGED
@@ -187,6 +187,9 @@ export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditC
187
187
  export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
188
188
  export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
189
189
  export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
190
+ export { useDomEditZOrder } from "./components/editor/useDomEditZOrder";
191
+ export type { DomEditZOrder } from "./components/editor/useDomEditZOrder";
192
+ export type { ZOrderAction } from "./components/editor/canvasContextMenuZOrder";
190
193
  export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
191
194
  export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
192
195
  export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";