@hyperframes/studio 0.8.100 → 0.8.101
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-fWt87gyh.js → hyperframes-player-CFJLrHRF.js} +1 -1
- package/dist/assets/{index-D6TjRAvz.js → index-BBNVnnyH.js} +114 -114
- package/dist/assets/{index-DofdzdxJ.js → index-C33EDBL2.js} +1 -1
- package/dist/assets/{index-D34Oy5DN.js → index-CJOzbslX.js} +1 -1
- package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
- package/dist/chunk-7X4XRP3L.js.map +1 -0
- package/dist/index.d.ts +91 -21
- package/dist/index.html +1 -1
- package/dist/index.js +403 -226
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +2 -0
- package/src/components/dock/Dock.test.tsx +78 -3
- package/src/components/dock/Dock.tsx +45 -18
- package/src/components/dock/dockLayout.ts +27 -14
- package/src/components/dock/dockLayoutSchema.ts +11 -4
- package/src/components/dock/dockLayoutStore.ts +4 -2
- package/src/hooks/appHotkeysDispatch.ts +1 -7
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
- package/src/hooks/useAppHotkeys.ts +18 -45
- package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
- package/src/hooks/useEditHistoryActions.test.tsx +67 -9
- package/src/hooks/useEditHistoryActions.ts +36 -8
- package/src/hooks/usePersistentEditHistory.test.ts +46 -0
- package/src/hooks/usePersistentEditHistory.ts +101 -19
- package/src/hooks/usePreviewPersistence.ts +25 -2
- package/src/hooks/useStudioExternalFileChanges.ts +7 -3
- package/src/index.ts +4 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -5
- package/src/player/components/timelineDragDrop.ts +29 -8
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/utils/domEditSaveQueue.ts +7 -2
- package/src/utils/gsapSoftReload.ts +1 -1
- package/src/utils/gsapUndoRestore.test.ts +46 -0
- package/src/utils/gsapUndoRestore.ts +166 -91
- package/src/utils/studioPendingEdits.ts +14 -5
- package/src/utils/studioUiPreferences.ts +8 -6
- 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(
|
|
45
|
+
function identityElementMap(root: ParentNode): Map<string, Element> | null {
|
|
44
46
|
const map = new Map<string, Element>();
|
|
45
|
-
for (const el of
|
|
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(
|
|
63
|
-
|
|
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(
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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).
|
|
169
|
-
* the active composition
|
|
170
|
-
* a
|
|
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.
|
|
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:
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
341
|
+
for (const target of plan.targets) syncElementAttributes(target.live, target.restored);
|
|
260
342
|
|
|
261
|
-
const
|
|
262
|
-
const
|
|
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.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 =
|
|
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 ? `${
|
|
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,
|
|
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
|
}
|