@rohal12/spindle 0.59.0 → 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 +360 -131
- package/dist/pkg/story-variables.js +21 -13
- package/package.json +1 -1
- package/src/automation/runner.ts +2 -1
- package/src/code-check.ts +11 -1
- package/src/components/PassageDialog.tsx +4 -0
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/Meter.tsx +5 -1
- 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 +30 -2
- package/src/store.ts +32 -9
- package/src/story-variables.ts +38 -65
- package/src/structural.ts +212 -24
- package/src/triggers.ts +22 -2
- package/src/utils/uuid.ts +18 -0
package/src/story-variables.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from './js-lexer';
|
|
12
12
|
import {
|
|
13
13
|
argPieces,
|
|
14
|
-
holdsCode,
|
|
15
14
|
parseOrError,
|
|
16
15
|
withParseCache,
|
|
17
16
|
type ParametersOf,
|
|
@@ -53,7 +52,6 @@ function declarationRegex(sigil: string): RegExp {
|
|
|
53
52
|
const VAR_PATH_RE = /\$(\w+(?:\.\w+)*)/y;
|
|
54
53
|
/** Quoted first argument of an input macro naming a story variable. */
|
|
55
54
|
const QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
|
|
56
|
-
const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
57
55
|
|
|
58
56
|
const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
|
|
59
57
|
|
|
@@ -131,22 +129,6 @@ export function parseStoryVariables(
|
|
|
131
129
|
return schema;
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
/**
|
|
135
|
-
* Extract for-loop local variable names from passage content.
|
|
136
|
-
* `{for @item of ...}` → "item"
|
|
137
|
-
* `{for @index, @item of ...}` → "index", "item"
|
|
138
|
-
*/
|
|
139
|
-
function extractForLocals(content: string): Set<string> {
|
|
140
|
-
const locals = new Set<string>();
|
|
141
|
-
let match: RegExpExecArray | null;
|
|
142
|
-
FOR_LOCAL_RE.lastIndex = 0;
|
|
143
|
-
while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
|
|
144
|
-
locals.add(match[1]!);
|
|
145
|
-
if (match[2]) locals.add(match[2]!);
|
|
146
|
-
}
|
|
147
|
-
return locals;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
132
|
/**
|
|
151
133
|
* Validate a single variable reference path (e.g. "player.health") against
|
|
152
134
|
* the schema. Returns an error message or null if valid.
|
|
@@ -154,7 +136,6 @@ function extractForLocals(content: string): Set<string> {
|
|
|
154
136
|
function validateRef(
|
|
155
137
|
ref: string,
|
|
156
138
|
schema: Map<string, VariableSchema>,
|
|
157
|
-
forLocals: Set<string>,
|
|
158
139
|
): string | null {
|
|
159
140
|
const parts = ref.split('.');
|
|
160
141
|
const rootName = parts[0]!;
|
|
@@ -162,9 +143,6 @@ function validateRef(
|
|
|
162
143
|
const nameError = variableNameError(rootName, '$' + rootName);
|
|
163
144
|
if (nameError) return nameError;
|
|
164
145
|
|
|
165
|
-
// Skip for-loop locals
|
|
166
|
-
if (forLocals.has(rootName)) return null;
|
|
167
|
-
|
|
168
146
|
const rootSchema = schema.get(rootName);
|
|
169
147
|
if (!rootSchema) {
|
|
170
148
|
return `Undeclared variable: $${ref}`;
|
|
@@ -252,8 +230,9 @@ function scanInterpolations(
|
|
|
252
230
|
* Report `$var` references in JavaScript code, lexed as the expression
|
|
253
231
|
* engine lexes it (`lexJs`): references in code, but none inside string or
|
|
254
232
|
* regex literals or comments, nor property names (`a.$b`, `{ $b: 1 }`).
|
|
255
|
-
* The text of string and template literals is scanned
|
|
256
|
-
*
|
|
233
|
+
* The text of string and template literals is not scanned: a string in code
|
|
234
|
+
* is literal text unless a macro argument that holds markup is read as such
|
|
235
|
+
* (see scanArgs); the `${…}` parts of template literals are code.
|
|
257
236
|
*/
|
|
258
237
|
function scanCode(
|
|
259
238
|
code: string,
|
|
@@ -270,7 +249,6 @@ function scanCode(
|
|
|
270
249
|
VAR_PATH_RE.lastIndex = ref.start;
|
|
271
250
|
onRef(VAR_PATH_RE.exec(code)![1]!);
|
|
272
251
|
}
|
|
273
|
-
for (const text of parsed.strings) scanInterpolations(text, onRef);
|
|
274
252
|
}
|
|
275
253
|
|
|
276
254
|
/** `scanCode` for code acorn can't parse: lexed leniently. */
|
|
@@ -279,8 +257,6 @@ function scanCodeLeniently(
|
|
|
279
257
|
onRef: RefCallback,
|
|
280
258
|
goal: JsGoal,
|
|
281
259
|
): void {
|
|
282
|
-
/** Open template literals: their text so far, null in an interpolation. */
|
|
283
|
-
const templates: { nesting: number; text: string | null }[] = [];
|
|
284
260
|
lexJs(
|
|
285
261
|
code,
|
|
286
262
|
{
|
|
@@ -289,32 +265,11 @@ function scanCodeLeniently(
|
|
|
289
265
|
VAR_PATH_RE.lastIndex = index;
|
|
290
266
|
onRef(VAR_PATH_RE.exec(code)![1]!);
|
|
291
267
|
},
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (template?.nesting === nesting) {
|
|
295
|
-
if (template.text === null) {
|
|
296
|
-
// The `}` ending an interpolation: back to the template's text
|
|
297
|
-
template.text = '';
|
|
298
|
-
} else if (text === '`' || text === '${') {
|
|
299
|
-
scanInterpolations(template.text, onRef);
|
|
300
|
-
if (text === '`') templates.pop();
|
|
301
|
-
else template.text = null;
|
|
302
|
-
} else {
|
|
303
|
-
template.text += text;
|
|
304
|
-
}
|
|
305
|
-
} else if (text === '`') {
|
|
306
|
-
templates.push({ nesting, text: '' });
|
|
307
|
-
} else if (text[0] === '"' || text[0] === "'") {
|
|
308
|
-
const { closed } = scanStringLiteral(text, 0);
|
|
309
|
-
scanInterpolations(text.slice(1, closed ? -1 : undefined), onRef);
|
|
310
|
-
}
|
|
311
|
-
// Regex literals and comments hold no references
|
|
312
|
-
},
|
|
268
|
+
// Strings, regex literals and comments hold no references
|
|
269
|
+
literal() {},
|
|
313
270
|
},
|
|
314
271
|
goal,
|
|
315
272
|
);
|
|
316
|
-
// An unterminated template literal's text
|
|
317
|
-
for (const t of templates) if (t.text) scanInterpolations(t.text, onRef);
|
|
318
273
|
}
|
|
319
274
|
|
|
320
275
|
/**
|
|
@@ -341,22 +296,29 @@ function collectPassageRefs(
|
|
|
341
296
|
}
|
|
342
297
|
|
|
343
298
|
/**
|
|
344
|
-
* Report the references in the
|
|
345
|
-
* (see ParameterDef.holds)
|
|
346
|
-
*
|
|
347
|
-
*
|
|
299
|
+
* Report the references in the arguments of a macro by what its parameters
|
|
300
|
+
* say each holds (see ParameterDef.holds): code is scanned as code and
|
|
301
|
+
* markup as markup, but literal text (a placeholder, a passage name) holds
|
|
302
|
+
* none. A macro without declared parameters, or arguments that do not
|
|
303
|
+
* parse, are scanned as code.
|
|
348
304
|
*/
|
|
349
|
-
function
|
|
305
|
+
function scanArgs(
|
|
350
306
|
token: Extract<Token, { type: 'macro' }>,
|
|
351
307
|
params: readonly ParameterDef[] | undefined,
|
|
352
308
|
onRef: RefCallback,
|
|
353
309
|
): void {
|
|
354
|
-
if (!
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
310
|
+
if (!token.rawArgs) return;
|
|
311
|
+
if (params) {
|
|
312
|
+
const pieces = [...argPieces(token.rawArgs, 0, params, token.name, '')];
|
|
313
|
+
if (!pieces.some((piece) => piece.kind === 'argument-error')) {
|
|
314
|
+
for (const piece of pieces) {
|
|
315
|
+
if (piece.kind === 'code') scanCode(piece.code, onRef, piece.goal);
|
|
316
|
+
else if (piece.kind === 'text') scanInterpolations(piece.text, onRef);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
358
319
|
}
|
|
359
320
|
}
|
|
321
|
+
scanCode(token.rawArgs, onRef);
|
|
360
322
|
}
|
|
361
323
|
|
|
362
324
|
/** Report the `$var` references in the tokens of `content`. */
|
|
@@ -371,6 +333,8 @@ function collectTokenRefs(
|
|
|
371
333
|
const token = tokens[t]!;
|
|
372
334
|
if (token.type === 'variable') {
|
|
373
335
|
if (token.scope === 'variable' && token.name) onRef(token.name);
|
|
336
|
+
} else if (token.type === 'link') {
|
|
337
|
+
scanInterpolations(token.display, onRef);
|
|
374
338
|
} else if (token.type === 'expression') {
|
|
375
339
|
scanCode(token.expression, onRef);
|
|
376
340
|
} else if (token.type === 'html') {
|
|
@@ -379,22 +343,32 @@ function collectTokenRefs(
|
|
|
379
343
|
else scanInterpolations(value, onRef, storeVarMacros);
|
|
380
344
|
}
|
|
381
345
|
} else if (token.type === 'macro' && !token.isClose) {
|
|
382
|
-
|
|
383
|
-
|
|
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);
|
|
384
350
|
|
|
385
351
|
if (storeVarMacros.has(token.name.toLowerCase())) {
|
|
386
352
|
const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
|
|
387
353
|
const quoted = QUOTED_VAR_ARG_RE.exec(first);
|
|
388
354
|
if (quoted) onRef(quoted[1]!);
|
|
355
|
+
// Unquoted, the variable is an argument of no declared role
|
|
356
|
+
else if (!/^["'`]/.test(first)) scanCode(first, onRef);
|
|
389
357
|
}
|
|
390
358
|
|
|
391
|
-
if (token.name === 'do') {
|
|
359
|
+
if (token.name.toLowerCase() === 'do') {
|
|
392
360
|
// A {do} body is JavaScript: scan its source text as code, however
|
|
393
361
|
// the markup tokens split it up.
|
|
394
362
|
let close = t + 1;
|
|
395
363
|
while (close < tokens.length) {
|
|
396
364
|
const c = tokens[close]!;
|
|
397
|
-
if (
|
|
365
|
+
if (
|
|
366
|
+
c.type === 'macro' &&
|
|
367
|
+
c.isClose &&
|
|
368
|
+
c.name.toLowerCase() === 'do'
|
|
369
|
+
) {
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
398
372
|
close++;
|
|
399
373
|
}
|
|
400
374
|
if (close < tokens.length) {
|
|
@@ -430,12 +404,11 @@ export function validatePassages(
|
|
|
430
404
|
// Don't validate the declarations or the SaveTitle code themselves
|
|
431
405
|
if (NOT_MARKUP.has(name)) continue;
|
|
432
406
|
|
|
433
|
-
const forLocals = extractForLocals(passage.content);
|
|
434
407
|
collectPassageRefs(
|
|
435
408
|
passage.content,
|
|
436
409
|
storeVarSet,
|
|
437
410
|
(ref) => {
|
|
438
|
-
const error = validateRef(ref, schema
|
|
411
|
+
const error = validateRef(ref, schema);
|
|
439
412
|
if (error) {
|
|
440
413
|
errors.push(`Passage "${name}": ${error}`);
|
|
441
414
|
}
|
package/src/structural.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface DeepCloneOptions {
|
|
|
36
36
|
* plain object, which would lose their prototype and identity.
|
|
37
37
|
*/
|
|
38
38
|
keepUnregistered?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The copies made so far, by original. Values copied with one map share
|
|
41
|
+
* their references: an object met again, in the same value or another,
|
|
42
|
+
* is the copy already made. The map is filled as values are copied.
|
|
43
|
+
*/
|
|
44
|
+
seen?: Map<object, object>;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
type TypedArrayCtor = new (
|
|
@@ -45,7 +51,7 @@ type TypedArrayCtor = new (
|
|
|
45
51
|
) => ArrayBufferView;
|
|
46
52
|
|
|
47
53
|
export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
48
|
-
const seen = new Map<object, object>();
|
|
54
|
+
const seen = options.seen ?? new Map<object, object>();
|
|
49
55
|
|
|
50
56
|
/** Record `copy` as the copy of `obj` before its contents are copied. */
|
|
51
57
|
const keep = <C extends object>(obj: object, copy: C): C => {
|
|
@@ -53,11 +59,20 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
53
59
|
return copy;
|
|
54
60
|
};
|
|
55
61
|
|
|
56
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Copy the own (enumerable) keys of `obj` into `copy`, symbol keys too: a
|
|
64
|
+
* save refuses them, which a copy must not hide.
|
|
65
|
+
*/
|
|
57
66
|
function copyKeys(obj: object, copy: object): object {
|
|
58
67
|
for (const key of Object.keys(obj)) {
|
|
59
68
|
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
60
69
|
}
|
|
70
|
+
for (const sym of Object.getOwnPropertySymbols(obj)) {
|
|
71
|
+
if (!Object.prototype.propertyIsEnumerable.call(obj, sym)) continue;
|
|
72
|
+
(copy as Record<symbol, unknown>)[sym] = clone(
|
|
73
|
+
(obj as Record<symbol, unknown>)[sym],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
61
76
|
return copy;
|
|
62
77
|
}
|
|
63
78
|
|
|
@@ -195,20 +210,25 @@ function loosePairs(): Pairs {
|
|
|
195
210
|
/**
|
|
196
211
|
* Pairs that also require the same reference structure: each object of one
|
|
197
212
|
* value pairs with exactly one of the other, so shared references and
|
|
198
|
-
* cycles are shared at the same places.
|
|
213
|
+
* cycles are shared at the same places. `ab` and `ba` hold the pairs (and
|
|
214
|
+
* may hold some already); those made are listed in `added`.
|
|
199
215
|
*/
|
|
200
|
-
function strictPairs(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
216
|
+
function strictPairs(
|
|
217
|
+
ab = new Map<object, object>(),
|
|
218
|
+
ba = new Map<object, object>(),
|
|
219
|
+
): Pairs & { added: [object, object][] } {
|
|
220
|
+
const added: [object, object][] = [];
|
|
221
|
+
const pairs = ((a, b) => {
|
|
204
222
|
const known = ab.get(a);
|
|
205
223
|
if (known !== undefined) return known === b;
|
|
206
224
|
if (ba.has(b)) return false;
|
|
207
225
|
ab.set(a, b);
|
|
208
226
|
ba.set(b, a);
|
|
227
|
+
added.push([a, b]);
|
|
209
228
|
return undefined;
|
|
210
|
-
};
|
|
229
|
+
}) as Pairs & { added: [object, object][] };
|
|
211
230
|
pairs.strict = true;
|
|
231
|
+
pairs.added = added;
|
|
212
232
|
return pairs;
|
|
213
233
|
}
|
|
214
234
|
|
|
@@ -225,28 +245,55 @@ export function deepEqualStrict(a: unknown, b: unknown): boolean {
|
|
|
225
245
|
* `prev` at the same place replaced by `prev`'s: the history moments of a
|
|
226
246
|
* save then share what did not change between them, which a save stores
|
|
227
247
|
* once. Containers on the way to a change are copied; neither value is
|
|
228
|
-
* changed.
|
|
248
|
+
* changed. The result has the reference structure of `curr`: what `curr`
|
|
249
|
+
* shares or cycles through stays so, and what it does not share stays
|
|
250
|
+
* apart, whichever parts are reused.
|
|
229
251
|
*/
|
|
230
252
|
export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
253
|
+
if (!isObjectValue(prev) || !isObjectValue(curr)) return curr;
|
|
254
|
+
/** The object of the result standing for each object of `curr` met. */
|
|
255
|
+
const done = new Map<object, object>();
|
|
256
|
+
/** The object of `curr` each object of `prev` in the result stands for. */
|
|
257
|
+
const used = new Map<object, object>();
|
|
258
|
+
|
|
259
|
+
/** `value` for the result: copied, as far as it is not met already. */
|
|
260
|
+
const fresh = <V>(value: V): V =>
|
|
261
|
+
isObjectValue(value)
|
|
262
|
+
? deepClone(value, { keepUnregistered: true, seen: done })
|
|
263
|
+
: value;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Whether `p` and `c` are equal and so can stand for each other, which
|
|
267
|
+
* needs each object in them to stand for one object only, in the whole
|
|
268
|
+
* result. Then every pair of objects they hold is placed in the result.
|
|
269
|
+
*/
|
|
270
|
+
function reuse(p: object, c: object): boolean {
|
|
271
|
+
const pairs = strictPairs(used, done);
|
|
272
|
+
if (equal(p, c, pairs)) return true;
|
|
273
|
+
for (const [a, b] of pairs.added) {
|
|
274
|
+
used.delete(a);
|
|
275
|
+
done.delete(b);
|
|
238
276
|
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function share(p: unknown, c: unknown): unknown {
|
|
281
|
+
if (!isObjectValue(c)) return c;
|
|
282
|
+
const placed = done.get(c);
|
|
283
|
+
if (placed !== undefined) return placed;
|
|
284
|
+
if (!isObjectValue(p)) return fresh(c);
|
|
285
|
+
if (reuse(p, c)) return p;
|
|
239
286
|
if (c instanceof Map && p instanceof Map) {
|
|
240
287
|
const out = new Map();
|
|
241
288
|
done.set(c, out);
|
|
242
|
-
for (const [k, v] of c) out.set(
|
|
289
|
+
for (const [k, v] of c) out.set(fresh(k), share(p.get(k), v));
|
|
243
290
|
return out;
|
|
244
291
|
}
|
|
245
292
|
if (Array.isArray(c) && Array.isArray(p)) {
|
|
246
293
|
const out = new Array(c.length) as unknown[];
|
|
247
294
|
done.set(c, out);
|
|
248
295
|
for (let i = 0; i < c.length; i++) {
|
|
249
|
-
if (i in c) out[i] =
|
|
296
|
+
if (i in c) out[i] = share(p[i], c[i]);
|
|
250
297
|
}
|
|
251
298
|
return out;
|
|
252
299
|
}
|
|
@@ -255,12 +302,11 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
|
255
302
|
done.set(c, out);
|
|
256
303
|
const cr = c as Record<string, unknown>;
|
|
257
304
|
for (const key of Object.keys(cr)) {
|
|
258
|
-
setOwn(out, key, hasOwn(p, key) ?
|
|
305
|
+
setOwn(out, key, share(hasOwn(p, key) ? p[key] : undefined, cr[key]));
|
|
259
306
|
}
|
|
260
307
|
return out;
|
|
261
308
|
}
|
|
262
|
-
|
|
263
|
-
return c;
|
|
309
|
+
return fresh(c);
|
|
264
310
|
}
|
|
265
311
|
return share(prev, curr) as T;
|
|
266
312
|
}
|
|
@@ -288,9 +334,17 @@ function equalBuiltin(
|
|
|
288
334
|
): boolean | undefined {
|
|
289
335
|
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
290
336
|
if (a instanceof RegExp) return String(a) === String(b);
|
|
291
|
-
if (ArrayBuffer.isView(a)
|
|
292
|
-
|
|
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
|
+
);
|
|
293
346
|
}
|
|
347
|
+
if (a instanceof ArrayBuffer) return equalBytes(a, b as ArrayBuffer);
|
|
294
348
|
if (a instanceof URL || a instanceof URLSearchParams) {
|
|
295
349
|
return String(a) === String(b);
|
|
296
350
|
}
|
|
@@ -475,7 +529,17 @@ export function keyChanges(
|
|
|
475
529
|
|
|
476
530
|
/** A write to one property path of an object, or its deletion. */
|
|
477
531
|
export type PathChange =
|
|
478
|
-
| {
|
|
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
|
+
}
|
|
479
543
|
| { path: string[]; deleted: true };
|
|
480
544
|
|
|
481
545
|
/**
|
|
@@ -514,6 +578,130 @@ export function diffPaths(
|
|
|
514
578
|
return changes;
|
|
515
579
|
}
|
|
516
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
|
+
|
|
517
705
|
/** Whether `target` already holds what `change` would write. */
|
|
518
706
|
export function isApplied(
|
|
519
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
|
+
}
|