@hyperframes/studio 0.8.100 → 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.
Files changed (51) hide show
  1. package/dist/assets/{hyperframes-player-fWt87gyh.js → hyperframes-player-BuRiFtrF.js} +1 -1
  2. package/dist/assets/{index-DofdzdxJ.js → index-BM35fjFf.js} +1 -1
  3. package/dist/assets/{index-D34Oy5DN.js → index-BT3sauee.js} +1 -1
  4. package/dist/assets/{index-D6TjRAvz.js → index-C2utDZt-.js} +114 -114
  5. package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
  6. package/dist/chunk-7X4XRP3L.js.map +1 -0
  7. package/dist/index.d.ts +101 -21
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1211 -990
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +2 -0
  14. package/src/components/dock/Dock.test.tsx +78 -3
  15. package/src/components/dock/Dock.tsx +45 -18
  16. package/src/components/dock/dockLayout.ts +27 -14
  17. package/src/components/dock/dockLayoutSchema.ts +11 -4
  18. package/src/components/dock/dockLayoutStore.ts +4 -2
  19. package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
  20. package/src/components/editor/CanvasContextMenu.tsx +7 -10
  21. package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
  22. package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
  23. package/src/components/editor/gsapAnimatesProperty.ts +0 -29
  24. package/src/components/editor/gsapLivePreview.test.ts +46 -1
  25. package/src/components/editor/propertyPanelHelpers.ts +3 -0
  26. package/src/components/editor/useDomEditZOrder.ts +185 -0
  27. package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
  28. package/src/domEditExports.test.tsx +92 -0
  29. package/src/hooks/appHotkeysDispatch.ts +1 -7
  30. package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
  31. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
  32. package/src/hooks/useAppHotkeys.ts +18 -45
  33. package/src/hooks/useDomEditCommits.test.tsx +94 -90
  34. package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
  35. package/src/hooks/useEditHistoryActions.test.tsx +67 -9
  36. package/src/hooks/useEditHistoryActions.ts +36 -8
  37. package/src/hooks/usePersistentEditHistory.test.ts +46 -0
  38. package/src/hooks/usePersistentEditHistory.ts +101 -19
  39. package/src/hooks/usePreviewPersistence.ts +25 -2
  40. package/src/hooks/useStudioExternalFileChanges.ts +7 -3
  41. package/src/index.ts +7 -0
  42. package/src/player/components/timelineDragDrop.test.tsx +61 -5
  43. package/src/player/components/timelineDragDrop.ts +29 -8
  44. package/src/player/components/useTimelineProviderState.tsx +1 -0
  45. package/src/utils/domEditSaveQueue.ts +7 -2
  46. package/src/utils/gsapSoftReload.ts +1 -1
  47. package/src/utils/gsapUndoRestore.test.ts +46 -0
  48. package/src/utils/gsapUndoRestore.ts +166 -91
  49. package/src/utils/studioPendingEdits.ts +14 -5
  50. package/src/utils/studioUiPreferences.ts +8 -6
  51. package/dist/chunk-U3EE4ACX.js.map +0 -1
@@ -8,6 +8,7 @@ import {
8
8
  extractGsapScriptText,
9
9
  findGsapScriptElements,
10
10
  } from "./gsapSoftReload";
11
+ import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
11
12
  import { findAuthoredElement, parseSavedSource } from "./authoredSource";
12
13
  import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
13
14
  import { markScenesStale } from "../player/sceneSwap";
@@ -22,6 +23,7 @@ export interface UndoRestoreFile {
22
23
  previous: string;
23
24
  restored: string;
24
25
  }
26
+ export type RestoreFiles = Record<string, UndoRestoreFile>;
25
27
 
26
28
  /**
27
29
  * Identity for the soft diff: `data-hf-id` when present, else `id`. Nearly
@@ -40,9 +42,9 @@ function elementIdentityKey(el: Element): string | null {
40
42
 
41
43
  const IDENTITY_SELECTOR = "[id], [data-hf-id]";
42
44
 
43
- function identityElementMap(doc: Document): Map<string, Element> | null {
45
+ function identityElementMap(root: ParentNode): Map<string, Element> | null {
44
46
  const map = new Map<string, Element>();
45
- for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) {
47
+ for (const el of root.querySelectorAll(IDENTITY_SELECTOR)) {
46
48
  const key = elementIdentityKey(el);
47
49
  if (!key) continue;
48
50
  // Ambiguous identity must full-reload; silently overwriting would restore
@@ -53,14 +55,26 @@ function identityElementMap(doc: Document): Map<string, Element> | null {
53
55
  return map;
54
56
  }
55
57
 
58
+ // A sub-composition file wraps its markup in a template; the preview inlines that markup into its host.
59
+ function parseRestoreSource(html: string): Document {
60
+ const doc = new DOMParser().parseFromString(html, "text/html");
61
+ for (const template of Array.from(doc.querySelectorAll("template")).filter(
62
+ isCompositionTemplate,
63
+ )) {
64
+ template.replaceWith(template.content);
65
+ }
66
+ return doc;
67
+ }
68
+
56
69
  // Strip identified elements to their bare identity attributes and blank GSAP
57
70
  // scripts, in place: docs that differ only in identified-element attributes/
58
71
  // inline-style/script text normalize equal; any residual difference is beyond
59
72
  // soft-reload's reach → caller full-reloads. Both identity attributes are
60
73
  // KEPT, so a change to `id`/`data-hf-id` themselves stays a residual
61
74
  // (structural) difference.
62
- function normalizeSoftResidual(doc: Document): void {
63
- for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) {
75
+ function normalizeSoftResidual(root: Element): void {
76
+ const self = root.matches(IDENTITY_SELECTOR) ? [root] : [];
77
+ for (const el of [...self, ...root.querySelectorAll(IDENTITY_SELECTOR)]) {
64
78
  const id = el.getAttribute("id");
65
79
  const hfId = el.getAttribute("data-hf-id");
66
80
  for (const name of [...el.getAttributeNames()]) {
@@ -69,7 +83,7 @@ function normalizeSoftResidual(doc: Document): void {
69
83
  if (id) el.setAttribute("id", id);
70
84
  if (hfId) el.setAttribute("data-hf-id", hfId);
71
85
  }
72
- for (const script of findGsapScriptElements(doc)) script.textContent = "";
86
+ for (const script of findGsapScriptElements(root)) script.textContent = "";
73
87
  }
74
88
 
75
89
  /** Same attribute set with identical values (order-insensitive). */
@@ -100,14 +114,11 @@ export function diffSoftReloadableRestore(
100
114
  previous: string,
101
115
  restored: string,
102
116
  ): { changedElementKeys: string[] } | null {
103
- let prevDoc: Document;
104
- let nextDoc: Document;
105
- try {
106
- prevDoc = new DOMParser().parseFromString(previous, "text/html");
107
- nextDoc = new DOMParser().parseFromString(restored, "text/html");
108
- } catch {
109
- return null;
110
- }
117
+ const keys = diffRestoreDocs(parseRestoreSource(previous), parseRestoreSource(restored));
118
+ return keys && { changedElementKeys: keys };
119
+ }
120
+
121
+ function diffRestoreDocs(prevDoc: Document, nextDoc: Document): string[] | null {
111
122
  const prevByKey = identityElementMap(prevDoc);
112
123
  const nextByKey = identityElementMap(nextDoc);
113
124
  if (!prevByKey || !nextByKey) return null;
@@ -121,10 +132,12 @@ export function diffSoftReloadableRestore(
121
132
  if (!attributesEqual(prevEl, nextEl)) changedElementKeys.push(key);
122
133
  }
123
134
  // Confirm nothing OUTSIDE identified-element attributes and GSAP scripts changed.
124
- normalizeSoftResidual(prevDoc);
125
- normalizeSoftResidual(nextDoc);
126
- if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
127
- return { changedElementKeys };
135
+ const [prevRest, nextRest] = [prevDoc, nextDoc].map((doc) => {
136
+ const root = doc.documentElement.cloneNode(true) as Element;
137
+ normalizeSoftResidual(root);
138
+ return root.outerHTML;
139
+ });
140
+ return prevRest === nextRest ? changedElementKeys : null;
128
141
  }
129
142
 
130
143
  /** Copy every attribute from `source` onto the live `target`, dropping extras. */
@@ -148,14 +161,10 @@ function syncStaleEditMarks(doc: Document, restored: string): void {
148
161
  }
149
162
  }
150
163
 
151
- function readGsapScriptTexts(html: string): string[] {
152
- const doc = new DOMParser().parseFromString(html, "text/html");
153
- return findGsapScriptElements(doc).map((script) => script.textContent ?? "");
154
- }
155
-
156
- function hasAmbiguousGsapScriptChange(previous: string, restored: string): boolean {
157
- const previousScripts = readGsapScriptTexts(previous);
158
- const restoredScripts = readGsapScriptTexts(restored);
164
+ function hasAmbiguousGsapScriptChange(
165
+ previousScripts: string[],
166
+ restoredScripts: string[],
167
+ ): boolean {
159
168
  if (previousScripts.length <= 1 && restoredScripts.length <= 1) return false;
160
169
  return (
161
170
  previousScripts.length !== restoredScripts.length ||
@@ -163,11 +172,124 @@ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boole
163
172
  );
164
173
  }
165
174
 
175
+ type RestoreTarget = { live: Element; restored: Element };
176
+ type RestorePlan = { targets: RestoreTarget[]; scripted: boolean };
177
+
178
+ /** Whether either side has a GSAP script, or null when its scripts rule out an in-place restore. */
179
+ function restoreScripted(prevDoc: Document, nextDoc: Document, isActive: boolean): boolean | null {
180
+ const [before, after] = [prevDoc, nextDoc].map((doc) =>
181
+ findGsapScriptElements(doc).map((script) => script.textContent ?? ""),
182
+ );
183
+ const scripted = before!.length > 0 || after!.length > 0;
184
+ // Only the active document's GSAP script can be re-run in place.
185
+ if (scripted && !isActive) return null;
186
+ return hasAmbiguousGsapScriptChange(before!, after!) ? null : scripted;
187
+ }
188
+
189
+ // The active document is the whole preview; a sub-composition lives in each host that inlines it.
190
+ function liveScopes(doc: Document, path: string, isActive: boolean): ParentNode[] {
191
+ if (isActive) return [doc];
192
+ return Array.from(doc.querySelectorAll(`[data-composition-file="${CSS.escape(path)}"]`));
193
+ }
194
+
195
+ function scopeTargets(
196
+ scope: ParentNode,
197
+ keys: string[],
198
+ restoredByKey: Map<string, Element>,
199
+ ): RestoreTarget[] | null {
200
+ const liveByKey = identityElementMap(scope);
201
+ if (!liveByKey) return null;
202
+ const targets: RestoreTarget[] = [];
203
+ for (const key of keys) {
204
+ const live = liveByKey.get(key);
205
+ const restored = restoredByKey.get(key);
206
+ // The preview rewrites a sub-composition's own root, so its attributes are not the file's.
207
+ if (!live || !restored || live.hasAttribute("data-hf-inner-root")) return null;
208
+ targets.push({ live, restored });
209
+ }
210
+ return targets;
211
+ }
212
+
213
+ function fileTargets(
214
+ doc: Document,
215
+ path: string,
216
+ isActive: boolean,
217
+ file: UndoRestoreFile,
218
+ ): RestorePlan | null {
219
+ const prevDoc = parseRestoreSource(file.previous);
220
+ const nextDoc = parseRestoreSource(file.restored);
221
+ const scripted = restoreScripted(prevDoc, nextDoc, isActive);
222
+ if (scripted === null) return null;
223
+ const keys = diffRestoreDocs(prevDoc, nextDoc);
224
+ const restoredByKey = identityElementMap(nextDoc);
225
+ if (!keys || !restoredByKey) return null;
226
+ const found = liveScopes(doc, path, isActive).map((scope) =>
227
+ scopeTargets(scope, keys, restoredByKey),
228
+ );
229
+ if (!found.length || found.includes(null)) return null;
230
+ return { targets: (found as RestoreTarget[][]).flat(), scripted };
231
+ }
232
+
233
+ /** Every live element a restore changes with its restored markup, or null when it needs a reload. */
234
+ function planRestoreTargets(
235
+ doc: Document,
236
+ activeDocPath: string,
237
+ files: RestoreFiles,
238
+ ): RestorePlan | null {
239
+ const plan: RestorePlan = { targets: [], scripted: false };
240
+ for (const [path, file] of Object.entries(files)) {
241
+ const found = fileTargets(doc, path, path === activeDocPath, file);
242
+ if (!found) return null;
243
+ plan.targets.push(...found.targets);
244
+ plan.scripted ||= found.scripted;
245
+ }
246
+ return plan;
247
+ }
248
+
249
+ // Rebind-only finalization (no script run); plain seek + manual reapply when the runtime has no rebind hook.
250
+ function finalizeInPlace(
251
+ iframe: HTMLIFrameElement,
252
+ win: PreviewWindow,
253
+ currentTime: number,
254
+ ): boolean {
255
+ if (applySoftReloadFinalization(iframe, currentTime)) return true;
256
+ try {
257
+ win.__player?.seek?.(currentTime);
258
+ win.__hfStudioManualEditsApply?.();
259
+ return true;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ /** Shows a restore without a GSAP script in place, or nothing; returns what puts the shown elements back. */
266
+ export function showRestoreInPlace(
267
+ iframe: HTMLIFrameElement | null,
268
+ activeCompPath: string | null,
269
+ files: RestoreFiles,
270
+ currentTime: number,
271
+ ): ((currentTime: number) => boolean) | null {
272
+ const doc = iframe?.contentDocument;
273
+ const win = iframe?.contentWindow as PreviewWindow | null;
274
+ const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
275
+ if (!iframe || !win || !plan || plan.scripted) return null;
276
+ const before = plan.targets.map(({ live }) => [live, live.cloneNode(false) as Element] as const);
277
+ for (const { live, restored } of plan.targets) syncElementAttributes(live, restored);
278
+ const putBack = (time: number) => {
279
+ if (iframe.contentDocument !== doc) return false;
280
+ for (const [live, attributes] of before) syncElementAttributes(live, attributes);
281
+ return finalizeInPlace(iframe, win, time);
282
+ };
283
+ if (finalizeInPlace(iframe, win, currentTime)) return putBack;
284
+ putBack(currentTime);
285
+ return null;
286
+ }
287
+
166
288
  /**
167
289
  * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
168
- * remount (which blanks the frame black and re-flashes the WebGL context). Only
169
- * the active composition — the document living in the root iframe — is eligible;
170
- * a sub-comp or multi-file restore falls back to `reloadPreview`.
290
+ * remount (which blanks the frame black and re-flashes the WebGL context). Eligible
291
+ * files are the active composition and any sub-composition the preview inlines;
292
+ * a file the preview does not show falls back to `reloadPreview`.
171
293
  *
172
294
  * The restore is soft-applied when its only differences are identified-element
173
295
  * (id / data-hf-id) attributes / inline-style and/or the GSAP script (see
@@ -184,10 +306,7 @@ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boole
184
306
  * lane move, timing shift, style tweak) → NO script execution — the
185
307
  * blink-free finalization only (seek + __hfForceTimelineRebind + manual
186
308
  * reapply, exactly the rebindPreviewTiming path), so timing-attribute
187
- * reverts refresh their visibility windows. Re-running an unchanged
188
- * script here used to be the biggest undo blink source: it tore down
189
- * and rebuilt live timelines (and full-reloaded whenever the script
190
- * couldn't be scoped) for restores that never touched it.
309
+ * reverts refresh their visibility windows.
191
310
  *
192
311
  * Returns "soft" when applied in place, "full" when it escalated to reloadPreview
193
312
  * (ineligible restore, missing target, or a permanent soft-reload failure).
@@ -196,7 +315,7 @@ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boole
196
315
  export function applyUndoRestoreToPreview(
197
316
  iframe: HTMLIFrameElement | null,
198
317
  activeCompPath: string | null,
199
- files: Record<string, UndoRestoreFile> | undefined,
318
+ files: RestoreFiles | undefined,
200
319
  currentTime: number,
201
320
  reload: () => void,
202
321
  ): "soft" | "full" {
@@ -210,62 +329,26 @@ export function applyUndoRestoreToPreview(
210
329
  markScenesStale(iframe, paths);
211
330
  reload();
212
331
  };
213
- // Soft path only covers the single active-comp document in the root iframe.
214
- if (!iframe || !files || paths.length !== 1 || paths[0] !== activeDocPath) {
332
+ const doc = iframe?.contentDocument;
333
+ const win = iframe?.contentWindow as PreviewWindow | null;
334
+ const plan = doc && files && win ? planRestoreTargets(doc, activeDocPath, files) : null;
335
+ if (!iframe || !doc || !files || !win || !plan) {
215
336
  reloadPreview();
216
337
  return "full";
217
338
  }
218
- const doc = iframe.contentDocument;
219
- const win = iframe.contentWindow as PreviewWindow | null;
220
- if (!doc || !win) {
221
- reloadPreview();
222
- return "full";
223
- }
224
- const { previous, restored } = files[activeDocPath]!;
225
- const diff = diffSoftReloadableRestore(previous, restored);
226
- if (!diff) {
227
- reloadPreview();
228
- return "full";
229
- }
230
- // A serialized snapshot cannot identify which of several GSAP scripts owns a
231
- // rewrite. Keep attribute-only restores soft when every script byte is equal,
232
- // but fail closed before touching the live DOM when an ambiguous script changed.
233
- if (hasAmbiguousGsapScriptChange(previous, restored)) {
234
- reloadPreview();
235
- return "full";
236
- }
237
-
238
- // Resolve every changed pair BEFORE touching the live DOM. A missing target
239
- // makes the soft restore incomplete, so escalate without leaving a partially
240
- // restored preview behind.
241
- const liveByKey = identityElementMap(doc);
242
- const restoredByKey = identityElementMap(new DOMParser().parseFromString(restored, "text/html"));
243
- if (!liveByKey || !restoredByKey) {
244
- reloadPreview();
245
- return "full";
246
- }
247
- const changedTargets: Array<{ live: Element; restored: Element }> = [];
248
- for (const key of diff.changedElementKeys) {
249
- const liveEl = liveByKey.get(key);
250
- const restoredEl = restoredByKey.get(key);
251
- if (!liveEl || !restoredEl) {
252
- reloadPreview();
253
- return "full";
254
- }
255
- changedTargets.push({ live: liveEl, restored: restoredEl });
256
- }
257
339
  // Sync each changed element's attributes onto the live DOM from the restored
258
340
  // markup, so the runtime's seek-reapply reads the reverted values.
259
- for (const target of changedTargets) syncElementAttributes(target.live, target.restored);
341
+ for (const target of plan.targets) syncElementAttributes(target.live, target.restored);
260
342
 
261
- const restoredScript = extractGsapScriptText(restored);
262
- const previousScript = extractGsapScriptText(previous);
343
+ const active = files[activeDocPath];
344
+ const restoredScript = active ? extractGsapScriptText(active.restored) : null;
345
+ const previousScript = active ? extractGsapScriptText(active.previous) : null;
263
346
  if (restoredScript && restoredScript !== previousScript) {
264
- syncStaleEditMarks(doc, restored);
347
+ syncStaleEditMarks(doc, active.restored);
265
348
  const result = applySoftReload(iframe, restoredScript, {
266
349
  onAsyncFailure: reloadPreview,
267
350
  currentTimeOverride: currentTime,
268
- authoredHtml: restored,
351
+ authoredHtml: active.restored,
269
352
  });
270
353
  if (result === "cannot-soft-reload") {
271
354
  reloadPreview();
@@ -274,16 +357,8 @@ export function applyUndoRestoreToPreview(
274
357
  return "soft";
275
358
  }
276
359
  // Script unchanged or absent — the live timelines are still valid; only the
277
- // synced attributes need to take effect. Rebind-only finalization (zero
278
- // script execution); plain seek + manual reapply as a degraded fallback when
279
- // the runtime rebind hook is unavailable.
280
- if (applySoftReloadFinalization(iframe, currentTime)) return "soft";
281
- try {
282
- win.__player?.seek?.(currentTime);
283
- win.__hfStudioManualEditsApply?.();
284
- } catch {
285
- reloadPreview();
286
- return "full";
287
- }
288
- return "soft";
360
+ // synced attributes need to take effect.
361
+ if (finalizeInPlace(iframe, win, currentTime)) return "soft";
362
+ reloadPreview();
363
+ return "full";
289
364
  }
@@ -27,6 +27,18 @@ function inspectDrainFailures(results: PromiseSettledResult<unknown>[]): {
27
27
  return { firstFailure };
28
28
  }
29
29
 
30
+ function focusedField(): HTMLElement | null {
31
+ const active = document.activeElement;
32
+ return active instanceof HTMLElement &&
33
+ active.matches('input, textarea, select, [contenteditable="true"], [role="textbox"]')
34
+ ? active
35
+ : null;
36
+ }
37
+
38
+ export function hasStudioPendingEdits(): boolean {
39
+ return pendingEditPromises.size > 0 || focusedField() !== null;
40
+ }
41
+
30
42
  export function trackStudioPendingEdit(
31
43
  result: Promise<unknown> | unknown,
32
44
  ): Promise<unknown> | undefined {
@@ -41,11 +53,8 @@ export function trackStudioPendingEdit(
41
53
  }
42
54
 
43
55
  export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrainResult> {
44
- const active = document.activeElement;
45
- if (
46
- active instanceof HTMLElement &&
47
- active.matches('input, textarea, select, [contenteditable="true"], [role="textbox"]')
48
- ) {
56
+ const active = focusedField();
57
+ if (active) {
49
58
  active.blur();
50
59
  // ponytail: Preserve synchronous/microtask blur commits, then cross one task boundary
51
60
  // so React effects triggered by the blur can register their flush listener.
@@ -60,8 +60,8 @@ function getBrowserStorage(): Storage | null {
60
60
  }
61
61
  }
62
62
 
63
- function storageKeyFor(projectId: string | null): string {
64
- return projectId ? `${STUDIO_UI_PREFERENCES_KEY}:${projectId}` : STUDIO_UI_PREFERENCES_KEY;
63
+ function storageKeyFor(projectId: string | null, key: string): string {
64
+ return projectId ? `${key}:${projectId}` : key;
65
65
  }
66
66
 
67
67
  // fallow-ignore-next-line complexity
@@ -157,24 +157,26 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
157
157
  export function readStudioUiPreferences(
158
158
  storage: Storage | null = getBrowserStorage(),
159
159
  projectId: string | null = null,
160
+ key: string = STUDIO_UI_PREFERENCES_KEY,
160
161
  ): StudioUiPreferences {
161
- const scoped = readStorage(storage, storageKeyFor(projectId));
162
+ const scoped = readStorage(storage, storageKeyFor(projectId, key));
162
163
  if (!projectId || Object.keys(scoped).length > 0) return scoped;
163
- return readStorage(storage, STUDIO_UI_PREFERENCES_KEY);
164
+ return readStorage(storage, key);
164
165
  }
165
166
 
166
167
  export function writeStudioUiPreferences(
167
168
  patch: StudioUiPreferences,
168
169
  storage: Storage | null = getBrowserStorage(),
169
170
  projectId: string | null = null,
171
+ key: string = STUDIO_UI_PREFERENCES_KEY,
170
172
  ) {
171
173
  if (!storage) return;
172
174
  try {
173
175
  const next = {
174
- ...readStudioUiPreferences(storage, projectId),
176
+ ...readStudioUiPreferences(storage, projectId, key),
175
177
  ...patch,
176
178
  };
177
- storage.setItem(storageKeyFor(projectId), JSON.stringify(next));
179
+ storage.setItem(storageKeyFor(projectId, key), JSON.stringify(next));
178
180
  } catch {
179
181
  /* localStorage may be unavailable or full */
180
182
  }