@rohal12/spindle 0.59.1 → 0.59.2
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/pkg/format.js +1 -1
- package/dist/pkg/headless.js +241 -41
- package/dist/pkg/story-variables.js +20 -12
- package/package.json +1 -1
- package/src/code-check.ts +9 -0
- package/src/components/PassageDialog.tsx +4 -0
- package/src/components/macros/Type.tsx +3 -1
- package/src/components/macros/macro-args.ts +5 -4
- package/src/execute-mutation.ts +118 -18
- package/src/saves/save-manager.ts +4 -3
- package/src/saves/storage.ts +16 -1
- package/src/store.ts +3 -2
- package/src/story-variables.ts +13 -2
- package/src/structural.ts +145 -3
- package/src/triggers.ts +22 -2
- package/src/utils/uuid.ts +18 -0
package/src/execute-mutation.ts
CHANGED
|
@@ -10,13 +10,18 @@ import { useStoryStore } from './store';
|
|
|
10
10
|
import type { StoryState, VariableNamespaces } from './store';
|
|
11
11
|
import { execute } from './expression';
|
|
12
12
|
import {
|
|
13
|
+
aliasChanges,
|
|
13
14
|
applyChange,
|
|
14
15
|
deepClone,
|
|
15
16
|
deepEqual,
|
|
16
17
|
diffPaths,
|
|
18
|
+
existingObjects,
|
|
17
19
|
isApplied,
|
|
18
20
|
isMergeable,
|
|
21
|
+
locateObjects,
|
|
19
22
|
mergeKeys,
|
|
23
|
+
pathKey,
|
|
24
|
+
underChange,
|
|
20
25
|
type PathChange,
|
|
21
26
|
} from './structural';
|
|
22
27
|
import { getByPath } from './utils/object-path';
|
|
@@ -71,6 +76,39 @@ export function readState(): StoryState {
|
|
|
71
76
|
const cloneValue = <T>(value: T): T =>
|
|
72
77
|
deepClone(value, { keepUnregistered: true });
|
|
73
78
|
|
|
79
|
+
/** Copies values for one target, so what they share they share there too. */
|
|
80
|
+
type Cloner = <T>(value: T) => T;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A cloner of values written into `target`. The references among the values
|
|
84
|
+
* it copies are kept (`$b = $a` writes one array twice: the two paths get
|
|
85
|
+
* one copy), and, given where the objects of the source are (see
|
|
86
|
+
* locateObjects), an object that is one the target holds already is that
|
|
87
|
+
* one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
|
|
88
|
+
*/
|
|
89
|
+
function clonerFor(
|
|
90
|
+
target: Record<string, unknown>,
|
|
91
|
+
paths?: ReadonlyMap<object, string[]>,
|
|
92
|
+
): Cloner {
|
|
93
|
+
const seen = paths ? existingObjects(paths, target) : new Map();
|
|
94
|
+
return (value) => deepClone(value, { keepUnregistered: true, seen });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One cloner per target copy, made when first used. */
|
|
98
|
+
const clonersFor = (
|
|
99
|
+
paths?: (target: Record<string, unknown>) => ReadonlyMap<object, string[]>,
|
|
100
|
+
) => {
|
|
101
|
+
const made = new Map<object, Cloner>();
|
|
102
|
+
return (target: Record<string, unknown>): Cloner => {
|
|
103
|
+
let cloner = made.get(target);
|
|
104
|
+
if (!cloner) {
|
|
105
|
+
cloner = clonerFor(target, paths?.(target));
|
|
106
|
+
made.set(target, cloner);
|
|
107
|
+
}
|
|
108
|
+
return cloner;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
74
112
|
/**
|
|
75
113
|
* A frozen copy of a value read from a working copy, matching what reads
|
|
76
114
|
* from the (frozen) store return: writing to it fails instead of changing
|
|
@@ -94,6 +132,7 @@ function mirror(
|
|
|
94
132
|
ns: NamespaceName,
|
|
95
133
|
change: PathChange,
|
|
96
134
|
scopes: readonly MutationScope[] = activeScopes,
|
|
135
|
+
cloner: (target: Record<string, unknown>) => Cloner = clonerOf,
|
|
97
136
|
): void {
|
|
98
137
|
const root = change.path[0]!;
|
|
99
138
|
for (const scope of scopes) {
|
|
@@ -103,7 +142,7 @@ function mirror(
|
|
|
103
142
|
copy,
|
|
104
143
|
change.deleted
|
|
105
144
|
? change
|
|
106
|
-
: { ...change, value:
|
|
145
|
+
: { ...change, value: cloner(copy)(change.value) },
|
|
107
146
|
);
|
|
108
147
|
} catch {
|
|
109
148
|
const stored = draft[ns][root];
|
|
@@ -119,6 +158,9 @@ function mirror(
|
|
|
119
158
|
}
|
|
120
159
|
}
|
|
121
160
|
|
|
161
|
+
/** A cloner of a copy of its own (no references shared with others). */
|
|
162
|
+
const clonerOf = (target: Record<string, unknown>): Cloner => clonerFor(target);
|
|
163
|
+
|
|
122
164
|
const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
|
|
123
165
|
|
|
124
166
|
/**
|
|
@@ -244,10 +286,19 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
244
286
|
own.length ? writtenPaths(view[ns], next[ns], own) : [],
|
|
245
287
|
] as const;
|
|
246
288
|
});
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
// Each target takes its own copies, which share what the written values do
|
|
290
|
+
const cloners = clonersFor();
|
|
291
|
+
const owned = (
|
|
292
|
+
change: PathChange,
|
|
293
|
+
target: Record<string, unknown>,
|
|
294
|
+
): PathChange =>
|
|
295
|
+
change.deleted
|
|
296
|
+
? change
|
|
297
|
+
: { ...change, value: cloners(target)(change.value) };
|
|
249
298
|
for (const [ns, list] of changes) {
|
|
250
|
-
for (const change of list)
|
|
299
|
+
for (const change of list) {
|
|
300
|
+
applyChange(inner.work[ns], owned(change, inner.work[ns]));
|
|
301
|
+
}
|
|
251
302
|
}
|
|
252
303
|
|
|
253
304
|
return (draft) => {
|
|
@@ -266,7 +317,7 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
266
317
|
for (const change of list) {
|
|
267
318
|
const root = change.path[0]!;
|
|
268
319
|
try {
|
|
269
|
-
applyChange(namespaces[ns], owned(change));
|
|
320
|
+
applyChange(namespaces[ns], owned(change, namespaces[ns]));
|
|
270
321
|
} catch {
|
|
271
322
|
if (hasOwn(inner.work[ns], root)) {
|
|
272
323
|
namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
@@ -274,7 +325,7 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
274
325
|
delete namespaces[ns][root];
|
|
275
326
|
}
|
|
276
327
|
}
|
|
277
|
-
mirror(namespaces, ns, change);
|
|
328
|
+
mirror(namespaces, ns, change, activeScopes, cloners);
|
|
278
329
|
}
|
|
279
330
|
}
|
|
280
331
|
};
|
|
@@ -312,7 +363,7 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
312
363
|
scope,
|
|
313
364
|
keepRunning,
|
|
314
365
|
changes: NAMESPACES.map(
|
|
315
|
-
(ns) => [ns,
|
|
366
|
+
(ns) => [ns, changesOf(scope.base[ns], scope.work[ns])] as const,
|
|
316
367
|
),
|
|
317
368
|
}));
|
|
318
369
|
const changed = all.filter(({ changes }) =>
|
|
@@ -336,35 +387,84 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
336
387
|
}
|
|
337
388
|
|
|
338
389
|
function commitRecipe(draft: VariableNamespaces): void {
|
|
339
|
-
all.forEach(({ scope,
|
|
340
|
-
|
|
390
|
+
all.forEach(({ scope, changes }, i) => {
|
|
391
|
+
// The copies the store and the enclosing mutations take are their own,
|
|
392
|
+
// and keep the references of the values written (one object written
|
|
393
|
+
// at two paths is one object there) and to the objects they hold
|
|
394
|
+
// already (see clonerFor). The code's working copy stays its own.
|
|
395
|
+
const paths = new Map(
|
|
396
|
+
changes.map(([ns, list]) => [ns, locateObjects(scope.work[ns], list)]),
|
|
397
|
+
);
|
|
398
|
+
const own = new Map(
|
|
399
|
+
changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]),
|
|
400
|
+
);
|
|
401
|
+
const enclosing = all.slice(0, i).map((c) => c.scope);
|
|
402
|
+
const enclosingCloners = new Map(
|
|
403
|
+
changes.map(([ns]) => [ns, clonersFor(() => paths.get(ns)!)]),
|
|
404
|
+
);
|
|
341
405
|
for (const [ns, list] of changes) {
|
|
342
406
|
const replaced = new Set<string>();
|
|
343
407
|
for (const change of list) {
|
|
344
408
|
const root = change.path[0]!;
|
|
345
|
-
|
|
346
|
-
if (replaced.has(root) || isApplied(draft[ns], change)) continue;
|
|
409
|
+
if (replaced.has(root)) continue;
|
|
347
410
|
try {
|
|
348
|
-
|
|
349
|
-
draft[ns],
|
|
350
|
-
|
|
351
|
-
|
|
411
|
+
if (change.deleted) {
|
|
412
|
+
if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
// A changed path already holding the value keeps its reference;
|
|
416
|
+
// an alias change is one of reference only: it holds when the
|
|
417
|
+
// very object is there.
|
|
418
|
+
const value = change.alias ? own.get(ns)!(change.value) : undefined;
|
|
419
|
+
if (
|
|
420
|
+
change.alias
|
|
421
|
+
? Object.is(getByPath(draft[ns], change.path), value)
|
|
422
|
+
: isApplied(draft[ns], change)
|
|
423
|
+
) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
applyChange(draft[ns], {
|
|
427
|
+
...change,
|
|
428
|
+
value: change.alias ? value : own.get(ns)!(change.value),
|
|
429
|
+
});
|
|
352
430
|
} catch {
|
|
353
431
|
// An intermediate object the code wrote into is gone from the
|
|
354
432
|
// store: the code's view of the whole root wins.
|
|
355
|
-
draft[ns][root] = own(scope.work[ns][root]);
|
|
433
|
+
draft[ns][root] = own.get(ns)!(scope.work[ns][root]);
|
|
356
434
|
replaced.add(root);
|
|
357
435
|
}
|
|
358
436
|
}
|
|
359
437
|
// Hand the changes to the mutations this one runs inside, before
|
|
360
438
|
// watchers fired by this update run.
|
|
361
|
-
const
|
|
362
|
-
|
|
439
|
+
for (const change of list) {
|
|
440
|
+
mirror(draft, ns, change, enclosing, enclosingCloners.get(ns)!);
|
|
441
|
+
}
|
|
363
442
|
}
|
|
364
443
|
});
|
|
365
444
|
}
|
|
366
445
|
}
|
|
367
446
|
|
|
447
|
+
/**
|
|
448
|
+
* The changes of the code to a namespace: its property paths that differ
|
|
449
|
+
* (see diffPaths), and those that hold equal content but another object
|
|
450
|
+
* than they did (see aliasChanges), those first.
|
|
451
|
+
*/
|
|
452
|
+
function changesOf(
|
|
453
|
+
base: Record<string, unknown>,
|
|
454
|
+
work: Record<string, unknown>,
|
|
455
|
+
): PathChange[] {
|
|
456
|
+
const written = diffPaths(base, work);
|
|
457
|
+
const keys = new Set(written.map((c) => pathKey(c.path)));
|
|
458
|
+
const aliases: PathChange[] = [];
|
|
459
|
+
for (const change of aliasChanges(base, work)) {
|
|
460
|
+
// Written whole by a change at it or above it
|
|
461
|
+
if (underChange(change.path, keys)) continue;
|
|
462
|
+
keys.add(pathKey(change.path));
|
|
463
|
+
aliases.push(change);
|
|
464
|
+
}
|
|
465
|
+
return [...aliases, ...written];
|
|
466
|
+
}
|
|
467
|
+
|
|
368
468
|
/** Every running mutation, to commit with its code going on. */
|
|
369
469
|
const running = (): Commit[] =>
|
|
370
470
|
activeScopes.map((scope) => ({ scope, keepRunning: true }));
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
SaveExport,
|
|
8
8
|
StorageInfo,
|
|
9
9
|
} from './types';
|
|
10
|
+
import { randomUUID } from '../utils/uuid';
|
|
10
11
|
import { checkSaveExport, estimatePayloadBytes } from './types';
|
|
11
12
|
import {
|
|
12
13
|
decodePayload,
|
|
@@ -130,7 +131,7 @@ export const startNewPlaythrough = queued(startNewPlaythroughNow);
|
|
|
130
131
|
|
|
131
132
|
async function startNewPlaythroughNow(
|
|
132
133
|
ifid: string,
|
|
133
|
-
id: string =
|
|
134
|
+
id: string = randomUUID(),
|
|
134
135
|
): Promise<string> {
|
|
135
136
|
const backend = await getBackend();
|
|
136
137
|
const num = await nextPlaythroughNumber(ifid);
|
|
@@ -296,7 +297,7 @@ async function createSaveNow(
|
|
|
296
297
|
): Promise<SaveRecord> {
|
|
297
298
|
const now = new Date().toISOString();
|
|
298
299
|
const meta: SaveMeta = {
|
|
299
|
-
id:
|
|
300
|
+
id: randomUUID(),
|
|
300
301
|
ifid,
|
|
301
302
|
playthroughId,
|
|
302
303
|
createdAt: now,
|
|
@@ -774,7 +775,7 @@ async function prepareImport(data: unknown, ifid: string): Promise<SaveRecord> {
|
|
|
774
775
|
|
|
775
776
|
// Re-assign a new ID to avoid collisions
|
|
776
777
|
const record = deepClone(data.save);
|
|
777
|
-
record.meta.id =
|
|
778
|
+
record.meta.id = randomUUID();
|
|
778
779
|
record.meta.updatedAt = new Date().toISOString();
|
|
779
780
|
// The save now belongs to this story: it is listed, loaded and cleared
|
|
780
781
|
// with it, whatever its own metadata says
|
package/src/saves/storage.ts
CHANGED
|
@@ -289,7 +289,16 @@ function createIDBBackend(): StorageBackend {
|
|
|
289
289
|
}
|
|
290
290
|
};
|
|
291
291
|
|
|
292
|
-
req.onsuccess = () =>
|
|
292
|
+
req.onsuccess = () => {
|
|
293
|
+
const db = req.result;
|
|
294
|
+
// Let another tab delete or upgrade the database: close at once and
|
|
295
|
+
// reopen on the next use.
|
|
296
|
+
db.onversionchange = () => {
|
|
297
|
+
db.close();
|
|
298
|
+
dbPromise = null;
|
|
299
|
+
};
|
|
300
|
+
resolve(db);
|
|
301
|
+
};
|
|
293
302
|
req.onerror = () => reject(req.error);
|
|
294
303
|
});
|
|
295
304
|
|
|
@@ -361,6 +370,12 @@ function createIDBBackend(): StorageBackend {
|
|
|
361
370
|
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
362
371
|
req.onsuccess = () => resolve();
|
|
363
372
|
req.onerror = () => reject(req.error);
|
|
373
|
+
req.onblocked = () =>
|
|
374
|
+
reject(
|
|
375
|
+
new Error(
|
|
376
|
+
'Cannot clear the saved data: another tab or window of this site still has it open. Close the other tabs and try again.',
|
|
377
|
+
),
|
|
378
|
+
);
|
|
364
379
|
});
|
|
365
380
|
});
|
|
366
381
|
}
|
package/src/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { create } from './preact-store';
|
|
2
|
+
import { randomUUID } from './utils/uuid';
|
|
2
3
|
import { immer } from 'zustand/middleware/immer';
|
|
3
4
|
import type { StateCreator } from 'zustand/vanilla';
|
|
4
5
|
import {
|
|
@@ -595,7 +596,7 @@ function switchToPlaythrough(id: string, stored: Promise<unknown>): void {
|
|
|
595
596
|
|
|
596
597
|
/** Move the running game to a new playthrough at once (see restart). */
|
|
597
598
|
function switchToNewPlaythrough(ifid: string): void {
|
|
598
|
-
const id =
|
|
599
|
+
const id = randomUUID();
|
|
599
600
|
const stored = startNewPlaythrough(ifid, id).catch((err) => {
|
|
600
601
|
console.error('spindle: failed to start new playthrough', err);
|
|
601
602
|
});
|
|
@@ -1484,7 +1485,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1484
1485
|
const ifid = storyData.ifid;
|
|
1485
1486
|
const current = knownPlaythroughId();
|
|
1486
1487
|
const established = playthroughSetup;
|
|
1487
|
-
const replacementId =
|
|
1488
|
+
const replacementId = randomUUID();
|
|
1488
1489
|
const deletion = smDeletePlaythroughData(ifid, playthroughId, {
|
|
1489
1490
|
current: current || established,
|
|
1490
1491
|
id: replacementId,
|
package/src/story-variables.ts
CHANGED
|
@@ -333,6 +333,8 @@ function collectTokenRefs(
|
|
|
333
333
|
const token = tokens[t]!;
|
|
334
334
|
if (token.type === 'variable') {
|
|
335
335
|
if (token.scope === 'variable' && token.name) onRef(token.name);
|
|
336
|
+
} else if (token.type === 'link') {
|
|
337
|
+
scanInterpolations(token.display, onRef);
|
|
336
338
|
} else if (token.type === 'expression') {
|
|
337
339
|
scanCode(token.expression, onRef);
|
|
338
340
|
} else if (token.type === 'html') {
|
|
@@ -342,6 +344,9 @@ function collectTokenRefs(
|
|
|
342
344
|
}
|
|
343
345
|
} else if (token.type === 'macro' && !token.isClose) {
|
|
344
346
|
scanArgs(token, parametersOf(token.name), onRef);
|
|
347
|
+
// Selectors (`{.{$cls} button}`) are interpolated when rendered
|
|
348
|
+
if (token.className) scanInterpolations(token.className, onRef);
|
|
349
|
+
if (token.id) scanInterpolations(token.id, onRef);
|
|
345
350
|
|
|
346
351
|
if (storeVarMacros.has(token.name.toLowerCase())) {
|
|
347
352
|
const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
|
|
@@ -351,13 +356,19 @@ function collectTokenRefs(
|
|
|
351
356
|
else if (!/^["'`]/.test(first)) scanCode(first, onRef);
|
|
352
357
|
}
|
|
353
358
|
|
|
354
|
-
if (token.name === 'do') {
|
|
359
|
+
if (token.name.toLowerCase() === 'do') {
|
|
355
360
|
// A {do} body is JavaScript: scan its source text as code, however
|
|
356
361
|
// the markup tokens split it up.
|
|
357
362
|
let close = t + 1;
|
|
358
363
|
while (close < tokens.length) {
|
|
359
364
|
const c = tokens[close]!;
|
|
360
|
-
if (
|
|
365
|
+
if (
|
|
366
|
+
c.type === 'macro' &&
|
|
367
|
+
c.isClose &&
|
|
368
|
+
c.name.toLowerCase() === 'do'
|
|
369
|
+
) {
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
361
372
|
close++;
|
|
362
373
|
}
|
|
363
374
|
if (close < tokens.length) {
|
package/src/structural.ts
CHANGED
|
@@ -334,9 +334,17 @@ function equalBuiltin(
|
|
|
334
334
|
): boolean | undefined {
|
|
335
335
|
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
336
336
|
if (a instanceof RegExp) return String(a) === String(b);
|
|
337
|
-
if (ArrayBuffer.isView(a)
|
|
338
|
-
|
|
337
|
+
if (ArrayBuffer.isView(a)) {
|
|
338
|
+
// A view is its place in its backing buffer, and the whole buffer (which
|
|
339
|
+
// other views can share): equal only if both match.
|
|
340
|
+
const v = b as ArrayBufferView;
|
|
341
|
+
return (
|
|
342
|
+
a.byteOffset === v.byteOffset &&
|
|
343
|
+
a.byteLength === v.byteLength &&
|
|
344
|
+
equal(a.buffer, v.buffer, assumed)
|
|
345
|
+
);
|
|
339
346
|
}
|
|
347
|
+
if (a instanceof ArrayBuffer) return equalBytes(a, b as ArrayBuffer);
|
|
340
348
|
if (a instanceof URL || a instanceof URLSearchParams) {
|
|
341
349
|
return String(a) === String(b);
|
|
342
350
|
}
|
|
@@ -521,7 +529,17 @@ export function keyChanges(
|
|
|
521
529
|
|
|
522
530
|
/** A write to one property path of an object, or its deletion. */
|
|
523
531
|
export type PathChange =
|
|
524
|
-
| {
|
|
532
|
+
| {
|
|
533
|
+
path: string[];
|
|
534
|
+
deleted: false;
|
|
535
|
+
value: unknown;
|
|
536
|
+
/**
|
|
537
|
+
* Set where the value is the same content as before and only which
|
|
538
|
+
* object the path refers to changed (see aliasChanges), so a store
|
|
539
|
+
* holding equal content there still takes the write.
|
|
540
|
+
*/
|
|
541
|
+
alias?: true;
|
|
542
|
+
}
|
|
525
543
|
| { path: string[]; deleted: true };
|
|
526
544
|
|
|
527
545
|
/**
|
|
@@ -560,6 +578,130 @@ export function diffPaths(
|
|
|
560
578
|
return changes;
|
|
561
579
|
}
|
|
562
580
|
|
|
581
|
+
export const pathKey = (path: readonly string[]): string =>
|
|
582
|
+
JSON.stringify(path);
|
|
583
|
+
|
|
584
|
+
/** Whether `path`, or one of its ancestors, is one of `keys`. */
|
|
585
|
+
export function underChange(
|
|
586
|
+
path: readonly string[],
|
|
587
|
+
keys: ReadonlySet<string>,
|
|
588
|
+
): boolean {
|
|
589
|
+
for (let n = 1; n <= path.length; n++) {
|
|
590
|
+
if (keys.has(pathKey(path.slice(0, n)))) return true;
|
|
591
|
+
}
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Where each object (or array, or other value) of `root` first appears, in
|
|
597
|
+
* depth-first order. Plain objects are entered; arrays and other values are
|
|
598
|
+
* leaves. A path at or below one of `skip` is left out.
|
|
599
|
+
*/
|
|
600
|
+
function firstPaths(
|
|
601
|
+
root: Record<string, unknown>,
|
|
602
|
+
skip: ReadonlySet<string> = new Set(),
|
|
603
|
+
): Map<object, string[]> {
|
|
604
|
+
const found = new Map<object, string[]>();
|
|
605
|
+
(function walk(node: Record<string, unknown>, path: string[]): void {
|
|
606
|
+
for (const key of Object.keys(node)) {
|
|
607
|
+
const value = node[key];
|
|
608
|
+
if (!isObjectValue(value) || found.has(value)) continue;
|
|
609
|
+
const at = [...path, key];
|
|
610
|
+
if (skip.has(pathKey(at))) continue;
|
|
611
|
+
found.set(value, at);
|
|
612
|
+
if (isMergeable(value)) walk(value, at);
|
|
613
|
+
}
|
|
614
|
+
})(root, []);
|
|
615
|
+
return found;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The paths whose object is another one in the references of `after` than in
|
|
620
|
+
* `before`, as to where its first appearance is: `$a = $b` makes `a` another
|
|
621
|
+
* name for the object of `b` even where both hold equal content, and a
|
|
622
|
+
* `diffPaths` over the content finds nothing. Only paths both hold an object
|
|
623
|
+
* at are reported, parents before children; each is a write of the object
|
|
624
|
+
* `after` holds.
|
|
625
|
+
*/
|
|
626
|
+
export function aliasChanges(
|
|
627
|
+
before: Record<string, unknown>,
|
|
628
|
+
after: Record<string, unknown>,
|
|
629
|
+
): PathChange[] {
|
|
630
|
+
const earlier = firstPaths(before);
|
|
631
|
+
const later = firstPaths(after);
|
|
632
|
+
const changes: PathChange[] = [];
|
|
633
|
+
(function walk(
|
|
634
|
+
b: Record<string, unknown>,
|
|
635
|
+
a: Record<string, unknown>,
|
|
636
|
+
path: string[],
|
|
637
|
+
): void {
|
|
638
|
+
for (const key of Object.keys(a)) {
|
|
639
|
+
if (!hasOwn(b, key)) continue;
|
|
640
|
+
const x = b[key];
|
|
641
|
+
const y = a[key];
|
|
642
|
+
if (!isObjectValue(x) || !isObjectValue(y)) continue;
|
|
643
|
+
const at = [...path, key];
|
|
644
|
+
const wasAt = earlier.get(x)!;
|
|
645
|
+
const isAt = later.get(y)!;
|
|
646
|
+
const k = pathKey(at);
|
|
647
|
+
if (pathKey(wasAt) !== pathKey(isAt)) {
|
|
648
|
+
changes.push({ path: at, deleted: false, value: y, alias: true });
|
|
649
|
+
}
|
|
650
|
+
// Only paths that are first appearances are entered: elsewhere the
|
|
651
|
+
// contents are those of the object met first.
|
|
652
|
+
if (
|
|
653
|
+
isMergeable(x) &&
|
|
654
|
+
mergesWith(x, y, false) &&
|
|
655
|
+
pathKey(isAt) === k &&
|
|
656
|
+
pathKey(wasAt) === k
|
|
657
|
+
) {
|
|
658
|
+
walk(x, y as Record<string, unknown>, at);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
})(before, after, []);
|
|
662
|
+
return changes;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Where the objects of `source` are, other than at or below the paths of
|
|
667
|
+
* `changes` (which write something new there), for `existingObjects`.
|
|
668
|
+
*/
|
|
669
|
+
export function locateObjects(
|
|
670
|
+
source: Record<string, unknown>,
|
|
671
|
+
changes: readonly PathChange[],
|
|
672
|
+
): Map<object, string[]> {
|
|
673
|
+
return firstPaths(source, new Set(changes.map((c) => pathKey(c.path))));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* A map for `deepClone`'s `seen`: an object of the source `paths` locate
|
|
678
|
+
* (see locateObjects) is copied as the object at the same path in `target`,
|
|
679
|
+
* so a value that refers to an object the store holds refers to it again
|
|
680
|
+
* after the copy, not to a copy of it. Objects are looked up in `target`
|
|
681
|
+
* when met, not up front.
|
|
682
|
+
*/
|
|
683
|
+
export function existingObjects(
|
|
684
|
+
paths: ReadonlyMap<object, string[]>,
|
|
685
|
+
target: Record<string, unknown>,
|
|
686
|
+
): Map<object, object> {
|
|
687
|
+
return new (class extends Map<object, object> {
|
|
688
|
+
override has(obj: object): boolean {
|
|
689
|
+
if (super.has(obj)) return true;
|
|
690
|
+
const path = paths.get(obj);
|
|
691
|
+
if (!path) return false;
|
|
692
|
+
const found = getByPath(target, path);
|
|
693
|
+
const like =
|
|
694
|
+
isObjectValue(found) &&
|
|
695
|
+
(Array.isArray(found)
|
|
696
|
+
? Array.isArray(obj)
|
|
697
|
+
: Object.getPrototypeOf(found) === Object.getPrototypeOf(obj));
|
|
698
|
+
if (!like) return false;
|
|
699
|
+
super.set(obj, found as object);
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
})();
|
|
703
|
+
}
|
|
704
|
+
|
|
563
705
|
/** Whether `target` already holds what `change` would write. */
|
|
564
706
|
export function isApplied(
|
|
565
707
|
target: Record<string, unknown>,
|
package/src/triggers.ts
CHANGED
|
@@ -305,15 +305,35 @@ export function registerDialogHost(callbacks: DialogHostCallbacks): () => void {
|
|
|
305
305
|
};
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The dialogs on display, bottom first, whoever opened them: the trigger
|
|
310
|
+
* host, `{dialog}` or a menubar button (each PassageDialog registers itself
|
|
311
|
+
* while mounted), as the function that closes each.
|
|
312
|
+
*/
|
|
313
|
+
const openDialogs: Array<() => void> = [];
|
|
314
|
+
|
|
315
|
+
/** Register a displayed dialog; returns the function that unregisters it. */
|
|
316
|
+
export function registerOpenDialog(close: () => void): () => void {
|
|
317
|
+
openDialogs.push(close);
|
|
318
|
+
return () => {
|
|
319
|
+
const i = openDialogs.lastIndexOf(close);
|
|
320
|
+
if (i >= 0) openDialogs.splice(i, 1);
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Close the topmost dialog. */
|
|
308
325
|
export function closeCurrentDialog(): void {
|
|
309
|
-
|
|
326
|
+
const top = openDialogs[openDialogs.length - 1];
|
|
327
|
+
if (top) top();
|
|
328
|
+
else dialogHostCallbacks?.close();
|
|
310
329
|
}
|
|
311
330
|
|
|
312
331
|
export function closeAllOpenDialogs(): void {
|
|
313
332
|
clearDialogQueue();
|
|
333
|
+
for (const close of [...openDialogs].reverse()) close();
|
|
314
334
|
dialogHostCallbacks?.closeAll();
|
|
315
335
|
}
|
|
316
336
|
|
|
317
337
|
export function isDialogShowing(): boolean {
|
|
318
|
-
return dialogHostCallbacks?.isOpen() ?? false;
|
|
338
|
+
return openDialogs.length > 0 || (dialogHostCallbacks?.isOpen() ?? false);
|
|
319
339
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A random UUID (v4). `crypto.randomUUID` exists only in secure contexts, so
|
|
3
|
+
* on a plain HTTP origin the ID is built from `crypto.getRandomValues`.
|
|
4
|
+
*/
|
|
5
|
+
export function randomUUID(): string {
|
|
6
|
+
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
|
7
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
8
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
9
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
10
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
|
|
11
|
+
return [
|
|
12
|
+
hex.slice(0, 4).join(''),
|
|
13
|
+
hex.slice(4, 6).join(''),
|
|
14
|
+
hex.slice(6, 8).join(''),
|
|
15
|
+
hex.slice(8, 10).join(''),
|
|
16
|
+
hex.slice(10).join(''),
|
|
17
|
+
].join('-');
|
|
18
|
+
}
|