@rohal12/spindle 0.59.1 → 0.59.3
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/dist/pkg/headless.js
CHANGED
|
@@ -32,6 +32,26 @@ function create() {
|
|
|
32
32
|
return (initializer) => bindPreact(createStore()(initializer));
|
|
33
33
|
}
|
|
34
34
|
//#endregion
|
|
35
|
+
//#region src/utils/uuid.ts
|
|
36
|
+
/**
|
|
37
|
+
* A random UUID (v4). `crypto.randomUUID` exists only in secure contexts, so
|
|
38
|
+
* on a plain HTTP origin the ID is built from `crypto.getRandomValues`.
|
|
39
|
+
*/
|
|
40
|
+
function randomUUID() {
|
|
41
|
+
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
42
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
43
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
44
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
45
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
|
46
|
+
return [
|
|
47
|
+
hex.slice(0, 4).join(""),
|
|
48
|
+
hex.slice(4, 6).join(""),
|
|
49
|
+
hex.slice(6, 8).join(""),
|
|
50
|
+
hex.slice(8, 10).join(""),
|
|
51
|
+
hex.slice(10).join("")
|
|
52
|
+
].join("-");
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
35
55
|
//#region src/markup/tokens.ts
|
|
36
56
|
/** Variable sigils: story ($), temporary (_), local (@), transient (%). */
|
|
37
57
|
var SIGIL_SCOPES$1 = {
|
|
@@ -13061,7 +13081,14 @@ function createIDBBackend() {
|
|
|
13061
13081
|
for (const [index, path] of Object.entries(indexes)) store.createIndex(index, path, { unique: false });
|
|
13062
13082
|
}
|
|
13063
13083
|
};
|
|
13064
|
-
req.onsuccess = () =>
|
|
13084
|
+
req.onsuccess = () => {
|
|
13085
|
+
const db = req.result;
|
|
13086
|
+
db.onversionchange = () => {
|
|
13087
|
+
db.close();
|
|
13088
|
+
dbPromise = null;
|
|
13089
|
+
};
|
|
13090
|
+
resolve(db);
|
|
13091
|
+
};
|
|
13065
13092
|
req.onerror = () => reject(req.error);
|
|
13066
13093
|
});
|
|
13067
13094
|
return dbPromise;
|
|
@@ -13117,6 +13144,7 @@ function createIDBBackend() {
|
|
|
13117
13144
|
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
13118
13145
|
req.onsuccess = () => resolve();
|
|
13119
13146
|
req.onerror = () => reject(req.error);
|
|
13147
|
+
req.onblocked = () => reject(/* @__PURE__ */ new Error("Cannot clear the saved data: another tab or window of this site still has it open. Close the other tabs and try again."));
|
|
13120
13148
|
});
|
|
13121
13149
|
});
|
|
13122
13150
|
}
|
|
@@ -13517,7 +13545,11 @@ function equalBytes(a, b) {
|
|
|
13517
13545
|
function equalBuiltin(a, b, assumed) {
|
|
13518
13546
|
if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
|
|
13519
13547
|
if (a instanceof RegExp) return String(a) === String(b);
|
|
13520
|
-
if (ArrayBuffer.isView(a)
|
|
13548
|
+
if (ArrayBuffer.isView(a)) {
|
|
13549
|
+
const v = b;
|
|
13550
|
+
return a.byteOffset === v.byteOffset && a.byteLength === v.byteLength && equal(a.buffer, v.buffer, assumed);
|
|
13551
|
+
}
|
|
13552
|
+
if (a instanceof ArrayBuffer) return equalBytes(a, b);
|
|
13521
13553
|
if (a instanceof URL || a instanceof URLSearchParams) return String(a) === String(b);
|
|
13522
13554
|
if (isBoxed(a)) return Object.is(a.valueOf(), b.valueOf());
|
|
13523
13555
|
if (isTemporal(a)) return String(a) === String(b);
|
|
@@ -13654,6 +13686,91 @@ function diffPaths(before, after) {
|
|
|
13654
13686
|
})(before, after, []);
|
|
13655
13687
|
return changes;
|
|
13656
13688
|
}
|
|
13689
|
+
var pathKey = (path) => JSON.stringify(path);
|
|
13690
|
+
/** Whether `path`, or one of its ancestors, is one of `keys`. */
|
|
13691
|
+
function underChange(path, keys) {
|
|
13692
|
+
for (let n = 1; n <= path.length; n++) if (keys.has(pathKey(path.slice(0, n)))) return true;
|
|
13693
|
+
return false;
|
|
13694
|
+
}
|
|
13695
|
+
/**
|
|
13696
|
+
* Where each object (or array, or other value) of `root` first appears, in
|
|
13697
|
+
* depth-first order. Plain objects are entered; arrays and other values are
|
|
13698
|
+
* leaves. A path at or below one of `skip` is left out.
|
|
13699
|
+
*/
|
|
13700
|
+
function firstPaths(root, skip = /* @__PURE__ */ new Set()) {
|
|
13701
|
+
const found = /* @__PURE__ */ new Map();
|
|
13702
|
+
(function walk(node, path) {
|
|
13703
|
+
for (const key of Object.keys(node)) {
|
|
13704
|
+
const value = node[key];
|
|
13705
|
+
if (!isObjectValue(value) || found.has(value)) continue;
|
|
13706
|
+
const at = [...path, key];
|
|
13707
|
+
if (skip.has(pathKey(at))) continue;
|
|
13708
|
+
found.set(value, at);
|
|
13709
|
+
if (isMergeable(value)) walk(value, at);
|
|
13710
|
+
}
|
|
13711
|
+
})(root, []);
|
|
13712
|
+
return found;
|
|
13713
|
+
}
|
|
13714
|
+
/**
|
|
13715
|
+
* The paths whose object is another one in the references of `after` than in
|
|
13716
|
+
* `before`, as to where its first appearance is: `$a = $b` makes `a` another
|
|
13717
|
+
* name for the object of `b` even where both hold equal content, and a
|
|
13718
|
+
* `diffPaths` over the content finds nothing. Only paths both hold an object
|
|
13719
|
+
* at are reported, parents before children; each is a write of the object
|
|
13720
|
+
* `after` holds.
|
|
13721
|
+
*/
|
|
13722
|
+
function aliasChanges(before, after) {
|
|
13723
|
+
const earlier = firstPaths(before);
|
|
13724
|
+
const later = firstPaths(after);
|
|
13725
|
+
const changes = [];
|
|
13726
|
+
(function walk(b, a, path) {
|
|
13727
|
+
for (const key of Object.keys(a)) {
|
|
13728
|
+
if (!hasOwn(b, key)) continue;
|
|
13729
|
+
const x = b[key];
|
|
13730
|
+
const y = a[key];
|
|
13731
|
+
if (!isObjectValue(x) || !isObjectValue(y)) continue;
|
|
13732
|
+
const at = [...path, key];
|
|
13733
|
+
const wasAt = earlier.get(x);
|
|
13734
|
+
const isAt = later.get(y);
|
|
13735
|
+
const k = pathKey(at);
|
|
13736
|
+
if (pathKey(wasAt) !== pathKey(isAt)) changes.push({
|
|
13737
|
+
path: at,
|
|
13738
|
+
deleted: false,
|
|
13739
|
+
value: y,
|
|
13740
|
+
alias: true
|
|
13741
|
+
});
|
|
13742
|
+
if (isMergeable(x) && mergesWith(x, y, false) && pathKey(isAt) === k && pathKey(wasAt) === k) walk(x, y, at);
|
|
13743
|
+
}
|
|
13744
|
+
})(before, after, []);
|
|
13745
|
+
return changes;
|
|
13746
|
+
}
|
|
13747
|
+
/**
|
|
13748
|
+
* Where the objects of `source` are, other than at or below the paths of
|
|
13749
|
+
* `changes` (which write something new there), for `existingObjects`.
|
|
13750
|
+
*/
|
|
13751
|
+
function locateObjects(source, changes) {
|
|
13752
|
+
return firstPaths(source, new Set(changes.map((c) => pathKey(c.path))));
|
|
13753
|
+
}
|
|
13754
|
+
/**
|
|
13755
|
+
* A map for `deepClone`'s `seen`: an object of the source `paths` locate
|
|
13756
|
+
* (see locateObjects) is copied as the object at the same path in `target`,
|
|
13757
|
+
* so a value that refers to an object the store holds refers to it again
|
|
13758
|
+
* after the copy, not to a copy of it. Objects are looked up in `target`
|
|
13759
|
+
* when met, not up front.
|
|
13760
|
+
*/
|
|
13761
|
+
function existingObjects(paths, target) {
|
|
13762
|
+
return new class extends Map {
|
|
13763
|
+
has(obj) {
|
|
13764
|
+
if (super.has(obj)) return true;
|
|
13765
|
+
const path = paths.get(obj);
|
|
13766
|
+
if (!path) return false;
|
|
13767
|
+
const found = getByPath(target, path);
|
|
13768
|
+
if (!(isObjectValue(found) && (Array.isArray(found) ? Array.isArray(obj) : Object.getPrototypeOf(found) === Object.getPrototypeOf(obj)))) return false;
|
|
13769
|
+
super.set(obj, found);
|
|
13770
|
+
return true;
|
|
13771
|
+
}
|
|
13772
|
+
}();
|
|
13773
|
+
}
|
|
13657
13774
|
/** Whether `target` already holds what `change` would write. */
|
|
13658
13775
|
function isApplied(target, change) {
|
|
13659
13776
|
const parent = getByPath(target, change.path.slice(0, -1));
|
|
@@ -13798,7 +13915,7 @@ var readingSlot = (read) => queued(async (ifid, slot) => read(await slotRecord(i
|
|
|
13798
13915
|
* saves issued before this resolves are tagged with it).
|
|
13799
13916
|
*/
|
|
13800
13917
|
var startNewPlaythrough = queued(startNewPlaythroughNow);
|
|
13801
|
-
async function startNewPlaythroughNow(ifid, id =
|
|
13918
|
+
async function startNewPlaythroughNow(ifid, id = randomUUID()) {
|
|
13802
13919
|
const backend = await getBackend();
|
|
13803
13920
|
const num = await nextPlaythroughNumber(ifid);
|
|
13804
13921
|
const record = {
|
|
@@ -13922,7 +14039,7 @@ var createSave = queued(createSaveNow);
|
|
|
13922
14039
|
async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
|
|
13923
14040
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
13924
14041
|
const record = storedRecord({
|
|
13925
|
-
id:
|
|
14042
|
+
id: randomUUID(),
|
|
13926
14043
|
ifid,
|
|
13927
14044
|
playthroughId,
|
|
13928
14045
|
createdAt: now,
|
|
@@ -14248,7 +14365,7 @@ async function prepareImport(data, ifid) {
|
|
|
14248
14365
|
checkSaveExport(data);
|
|
14249
14366
|
if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
|
|
14250
14367
|
const record = deepClone(data.save);
|
|
14251
|
-
record.meta.id =
|
|
14368
|
+
record.meta.id = randomUUID();
|
|
14252
14369
|
record.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
14253
14370
|
record.meta.ifid = ifid;
|
|
14254
14371
|
await ensurePlaythrough(ifid, record.meta.playthroughId, record.meta.createdAt);
|
|
@@ -14438,6 +14555,32 @@ function readState() {
|
|
|
14438
14555
|
}
|
|
14439
14556
|
var cloneValue = (value) => deepClone(value, { keepUnregistered: true });
|
|
14440
14557
|
/**
|
|
14558
|
+
* A cloner of values written into `target`. The references among the values
|
|
14559
|
+
* it copies are kept (`$b = $a` writes one array twice: the two paths get
|
|
14560
|
+
* one copy), and, given where the objects of the source are (see
|
|
14561
|
+
* locateObjects), an object that is one the target holds already is that
|
|
14562
|
+
* one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
|
|
14563
|
+
*/
|
|
14564
|
+
function clonerFor(target, paths) {
|
|
14565
|
+
const seen = paths ? existingObjects(paths, target) : /* @__PURE__ */ new Map();
|
|
14566
|
+
return (value) => deepClone(value, {
|
|
14567
|
+
keepUnregistered: true,
|
|
14568
|
+
seen
|
|
14569
|
+
});
|
|
14570
|
+
}
|
|
14571
|
+
/** One cloner per target copy, made when first used. */
|
|
14572
|
+
var clonersFor = (paths) => {
|
|
14573
|
+
const made = /* @__PURE__ */ new Map();
|
|
14574
|
+
return (target) => {
|
|
14575
|
+
let cloner = made.get(target);
|
|
14576
|
+
if (!cloner) {
|
|
14577
|
+
cloner = clonerFor(target, paths?.(target));
|
|
14578
|
+
made.set(target, cloner);
|
|
14579
|
+
}
|
|
14580
|
+
return cloner;
|
|
14581
|
+
};
|
|
14582
|
+
};
|
|
14583
|
+
/**
|
|
14441
14584
|
* A frozen copy of a value read from a working copy, matching what reads
|
|
14442
14585
|
* from the (frozen) store return: writing to it fails instead of changing
|
|
14443
14586
|
* the code's pending state behind its back.
|
|
@@ -14454,12 +14597,12 @@ function frozenCopy(value) {
|
|
|
14454
14597
|
* path (the code replaced or deleted an intermediate object), its root is
|
|
14455
14598
|
* replaced with the store's root as written.
|
|
14456
14599
|
*/
|
|
14457
|
-
function mirror(draft, ns, change, scopes = activeScopes) {
|
|
14600
|
+
function mirror(draft, ns, change, scopes = activeScopes, cloner = clonerOf) {
|
|
14458
14601
|
const root = change.path[0];
|
|
14459
14602
|
for (const scope of scopes) for (const copy of [scope.work[ns], scope.base[ns]]) try {
|
|
14460
14603
|
applyChange(copy, change.deleted ? change : {
|
|
14461
14604
|
...change,
|
|
14462
|
-
value:
|
|
14605
|
+
value: cloner(copy)(change.value)
|
|
14463
14606
|
});
|
|
14464
14607
|
} catch {
|
|
14465
14608
|
const stored = draft[ns][root];
|
|
@@ -14467,6 +14610,8 @@ function mirror(draft, ns, change, scopes = activeScopes) {
|
|
|
14467
14610
|
else delete copy[root];
|
|
14468
14611
|
}
|
|
14469
14612
|
}
|
|
14613
|
+
/** A cloner of a copy of its own (no references shared with others). */
|
|
14614
|
+
var clonerOf = (target) => clonerFor(target);
|
|
14470
14615
|
var NAMESPACE_KEYS$1 = new Set(NAMESPACES);
|
|
14471
14616
|
/**
|
|
14472
14617
|
* Runs store updates on the running code's working copies. They stay the
|
|
@@ -14566,11 +14711,12 @@ function routeStoreUpdate(recipe) {
|
|
|
14566
14711
|
const own = patches.filter((p) => p.path[0] === ns);
|
|
14567
14712
|
return [ns, own.length ? writtenPaths(view[ns], next[ns], own) : []];
|
|
14568
14713
|
});
|
|
14569
|
-
const
|
|
14714
|
+
const cloners = clonersFor();
|
|
14715
|
+
const owned = (change, target) => change.deleted ? change : {
|
|
14570
14716
|
...change,
|
|
14571
|
-
value:
|
|
14717
|
+
value: cloners(target)(change.value)
|
|
14572
14718
|
};
|
|
14573
|
-
for (const [ns, list] of changes) for (const change of list) applyChange(inner.work[ns], owned(change));
|
|
14719
|
+
for (const [ns, list] of changes) for (const change of list) applyChange(inner.work[ns], owned(change, inner.work[ns]));
|
|
14574
14720
|
return (draft) => {
|
|
14575
14721
|
const target = draft;
|
|
14576
14722
|
for (const key of Object.keys(next)) {
|
|
@@ -14581,12 +14727,12 @@ function routeStoreUpdate(recipe) {
|
|
|
14581
14727
|
for (const [ns, list] of changes) for (const change of list) {
|
|
14582
14728
|
const root = change.path[0];
|
|
14583
14729
|
try {
|
|
14584
|
-
applyChange(namespaces[ns], owned(change));
|
|
14730
|
+
applyChange(namespaces[ns], owned(change, namespaces[ns]));
|
|
14585
14731
|
} catch {
|
|
14586
14732
|
if (hasOwn(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
14587
14733
|
else delete namespaces[ns][root];
|
|
14588
14734
|
}
|
|
14589
|
-
mirror(namespaces, ns, change);
|
|
14735
|
+
mirror(namespaces, ns, change, activeScopes, cloners);
|
|
14590
14736
|
}
|
|
14591
14737
|
};
|
|
14592
14738
|
}
|
|
@@ -14614,7 +14760,7 @@ function commitScopes(commits) {
|
|
|
14614
14760
|
const all = commits.map(({ scope, keepRunning }) => ({
|
|
14615
14761
|
scope,
|
|
14616
14762
|
keepRunning,
|
|
14617
|
-
changes: NAMESPACES.map((ns) => [ns,
|
|
14763
|
+
changes: NAMESPACES.map((ns) => [ns, changesOf(scope.base[ns], scope.work[ns])])
|
|
14618
14764
|
}));
|
|
14619
14765
|
const changed = all.filter(({ changes }) => changes.some(([, list]) => list.length > 0));
|
|
14620
14766
|
if (changed.length === 0) return;
|
|
@@ -14626,29 +14772,53 @@ function commitScopes(commits) {
|
|
|
14626
14772
|
committing = false;
|
|
14627
14773
|
}
|
|
14628
14774
|
function commitRecipe(draft) {
|
|
14629
|
-
all.forEach(({ scope,
|
|
14630
|
-
const
|
|
14775
|
+
all.forEach(({ scope, changes }, i) => {
|
|
14776
|
+
const paths = new Map(changes.map(([ns, list]) => [ns, locateObjects(scope.work[ns], list)]));
|
|
14777
|
+
const own = new Map(changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]));
|
|
14778
|
+
const enclosing = all.slice(0, i).map((c) => c.scope);
|
|
14779
|
+
const enclosingCloners = new Map(changes.map(([ns]) => [ns, clonersFor(() => paths.get(ns))]));
|
|
14631
14780
|
for (const [ns, list] of changes) {
|
|
14632
14781
|
const replaced = /* @__PURE__ */ new Set();
|
|
14633
14782
|
for (const change of list) {
|
|
14634
14783
|
const root = change.path[0];
|
|
14635
|
-
if (replaced.has(root)
|
|
14784
|
+
if (replaced.has(root)) continue;
|
|
14636
14785
|
try {
|
|
14637
|
-
|
|
14786
|
+
if (change.deleted) {
|
|
14787
|
+
if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
|
|
14788
|
+
continue;
|
|
14789
|
+
}
|
|
14790
|
+
const value = change.alias ? own.get(ns)(change.value) : void 0;
|
|
14791
|
+
if (change.alias ? Object.is(getByPath(draft[ns], change.path), value) : isApplied(draft[ns], change)) continue;
|
|
14792
|
+
applyChange(draft[ns], {
|
|
14638
14793
|
...change,
|
|
14639
|
-
value: own(change.value)
|
|
14794
|
+
value: change.alias ? value : own.get(ns)(change.value)
|
|
14640
14795
|
});
|
|
14641
14796
|
} catch {
|
|
14642
|
-
draft[ns][root] = own(scope.work[ns][root]);
|
|
14797
|
+
draft[ns][root] = own.get(ns)(scope.work[ns][root]);
|
|
14643
14798
|
replaced.add(root);
|
|
14644
14799
|
}
|
|
14645
14800
|
}
|
|
14646
|
-
const
|
|
14647
|
-
for (const change of list) mirror(draft, ns, change, enclosing);
|
|
14801
|
+
for (const change of list) mirror(draft, ns, change, enclosing, enclosingCloners.get(ns));
|
|
14648
14802
|
}
|
|
14649
14803
|
});
|
|
14650
14804
|
}
|
|
14651
14805
|
}
|
|
14806
|
+
/**
|
|
14807
|
+
* The changes of the code to a namespace: its property paths that differ
|
|
14808
|
+
* (see diffPaths), and those that hold equal content but another object
|
|
14809
|
+
* than they did (see aliasChanges), those first.
|
|
14810
|
+
*/
|
|
14811
|
+
function changesOf(base, work) {
|
|
14812
|
+
const written = diffPaths(base, work);
|
|
14813
|
+
const keys = new Set(written.map((c) => pathKey(c.path)));
|
|
14814
|
+
const aliases = [];
|
|
14815
|
+
for (const change of aliasChanges(base, work)) {
|
|
14816
|
+
if (underChange(change.path, keys)) continue;
|
|
14817
|
+
keys.add(pathKey(change.path));
|
|
14818
|
+
aliases.push(change);
|
|
14819
|
+
}
|
|
14820
|
+
return [...aliases, ...written];
|
|
14821
|
+
}
|
|
14652
14822
|
/** Every running mutation, to commit with its code going on. */
|
|
14653
14823
|
var running = () => activeScopes.map((scope) => ({
|
|
14654
14824
|
scope,
|
|
@@ -14884,15 +15054,33 @@ function registerDialogHost(callbacks) {
|
|
|
14884
15054
|
if (dialogHostCallbacks === callbacks) dialogHostCallbacks = null;
|
|
14885
15055
|
};
|
|
14886
15056
|
}
|
|
15057
|
+
/**
|
|
15058
|
+
* The dialogs on display, bottom first, whoever opened them: the trigger
|
|
15059
|
+
* host, `{dialog}` or a menubar button (each PassageDialog registers itself
|
|
15060
|
+
* while mounted), as the function that closes each.
|
|
15061
|
+
*/
|
|
15062
|
+
var openDialogs = [];
|
|
15063
|
+
/** Register a displayed dialog; returns the function that unregisters it. */
|
|
15064
|
+
function registerOpenDialog(close) {
|
|
15065
|
+
openDialogs.push(close);
|
|
15066
|
+
return () => {
|
|
15067
|
+
const i = openDialogs.lastIndexOf(close);
|
|
15068
|
+
if (i >= 0) openDialogs.splice(i, 1);
|
|
15069
|
+
};
|
|
15070
|
+
}
|
|
15071
|
+
/** Close the topmost dialog. */
|
|
14887
15072
|
function closeCurrentDialog() {
|
|
14888
|
-
|
|
15073
|
+
const top = openDialogs[openDialogs.length - 1];
|
|
15074
|
+
if (top) top();
|
|
15075
|
+
else dialogHostCallbacks?.close();
|
|
14889
15076
|
}
|
|
14890
15077
|
function closeAllOpenDialogs() {
|
|
14891
15078
|
clearDialogQueue();
|
|
15079
|
+
for (const close of [...openDialogs].reverse()) close();
|
|
14892
15080
|
dialogHostCallbacks?.closeAll();
|
|
14893
15081
|
}
|
|
14894
15082
|
function isDialogShowing() {
|
|
14895
|
-
return dialogHostCallbacks?.isOpen() ?? false;
|
|
15083
|
+
return openDialogs.length > 0 || (dialogHostCallbacks?.isOpen() ?? false);
|
|
14896
15084
|
}
|
|
14897
15085
|
//#endregion
|
|
14898
15086
|
//#region src/runtime-errors.ts
|
|
@@ -15331,7 +15519,7 @@ function switchToPlaythrough(id, stored) {
|
|
|
15331
15519
|
}
|
|
15332
15520
|
/** Move the running game to a new playthrough at once (see restart). */
|
|
15333
15521
|
function switchToNewPlaythrough(ifid) {
|
|
15334
|
-
const id =
|
|
15522
|
+
const id = randomUUID();
|
|
15335
15523
|
switchToPlaythrough(id, startNewPlaythrough(ifid, id).catch((err) => {
|
|
15336
15524
|
console.error("spindle: failed to start new playthrough", err);
|
|
15337
15525
|
}));
|
|
@@ -15844,7 +16032,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
15844
16032
|
const ifid = storyData.ifid;
|
|
15845
16033
|
const current = knownPlaythroughId();
|
|
15846
16034
|
const established = playthroughSetup;
|
|
15847
|
-
const replacementId =
|
|
16035
|
+
const replacementId = randomUUID();
|
|
15848
16036
|
const deletion = deletePlaythroughData(ifid, playthroughId, {
|
|
15849
16037
|
current: current || established,
|
|
15850
16038
|
id: replacementId
|
|
@@ -16080,6 +16268,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
|
|
|
16080
16268
|
}, [markup, nobr]);
|
|
16081
16269
|
const panelRef = useRef(null);
|
|
16082
16270
|
useModalFocus(panelRef, ".dialog-body", dismissible, stableOnClose);
|
|
16271
|
+
useLayoutEffect(() => registerOpenDialog(stableOnClose), [stableOnClose]);
|
|
16083
16272
|
useLayoutEffect(() => {
|
|
16084
16273
|
if (panelRef.current) emitFromRender("dialogrender", passageName ?? "", panelRef.current);
|
|
16085
16274
|
}, []);
|
|
@@ -16677,7 +16866,7 @@ function unset(param) {
|
|
|
16677
16866
|
return param.type === "options" ? {} : void 0;
|
|
16678
16867
|
}
|
|
16679
16868
|
var WORD_RE = /\w+/y;
|
|
16680
|
-
var
|
|
16869
|
+
var NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
|
|
16681
16870
|
var WORD_OR_PUNCT_RE = /\S+/y;
|
|
16682
16871
|
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
16683
16872
|
function matchAt(re, src, pos) {
|
|
@@ -16686,7 +16875,8 @@ function matchAt(re, src, pos) {
|
|
|
16686
16875
|
}
|
|
16687
16876
|
/**
|
|
16688
16877
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
16689
|
-
* quoted string (with or without whitespace before it) or
|
|
16878
|
+
* quoted string (with or without whitespace before it) or number (signed,
|
|
16879
|
+
* fractional) after
|
|
16690
16880
|
* it as its value; keywords that aren't declared, and their values, are
|
|
16691
16881
|
* skipped. A keyword of a `string` parameter must be followed by a quoted
|
|
16692
16882
|
* string.
|
|
@@ -16705,7 +16895,7 @@ function readOptions(params, src, { at, spans }) {
|
|
|
16705
16895
|
let j = i;
|
|
16706
16896
|
while (j < src.length && isWhitespace(src[j])) j++;
|
|
16707
16897
|
const quoted = readQuoted(src, j);
|
|
16708
|
-
const digits = quoted || j === i ? null : matchAt(
|
|
16898
|
+
const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
|
|
16709
16899
|
if (quoted) {
|
|
16710
16900
|
val = quoted.value;
|
|
16711
16901
|
i = quoted.end;
|
|
@@ -17444,15 +17634,22 @@ function* codeAndText(src, tokens, parametersOf) {
|
|
|
17444
17634
|
const closeOf = (t, name) => tokens.findIndex((c, k) => k > t && c.type === "macro" && c.isClose && c.name.toLowerCase() === name);
|
|
17445
17635
|
for (let t = 0; t < tokens.length; t++) {
|
|
17446
17636
|
const token = tokens[t];
|
|
17447
|
-
if (token.type === "link")
|
|
17448
|
-
|
|
17449
|
-
|
|
17450
|
-
|
|
17451
|
-
|
|
17452
|
-
|
|
17453
|
-
|
|
17454
|
-
|
|
17455
|
-
|
|
17637
|
+
if (token.type === "link") {
|
|
17638
|
+
yield {
|
|
17639
|
+
kind: "passage",
|
|
17640
|
+
name: token.target,
|
|
17641
|
+
offset: token.targetStart,
|
|
17642
|
+
length: token.targetEnd - token.targetStart,
|
|
17643
|
+
macro: "link",
|
|
17644
|
+
label: src.slice(token.start, token.end)
|
|
17645
|
+
};
|
|
17646
|
+
if (token.display.includes("{")) yield {
|
|
17647
|
+
kind: "text",
|
|
17648
|
+
text: token.display,
|
|
17649
|
+
offset: locate(src, token.display, token.start),
|
|
17650
|
+
where: "In the label of a link: "
|
|
17651
|
+
};
|
|
17652
|
+
} else if (token.type === "expression") yield {
|
|
17456
17653
|
kind: "code",
|
|
17457
17654
|
code: token.expression,
|
|
17458
17655
|
offset: token.end - 1 - token.expression.length,
|
|
@@ -18059,22 +18256,25 @@ function collectTokenRefs(content, tokens, storeVarMacros, onRef, parametersOf =
|
|
|
18059
18256
|
const token = tokens[t];
|
|
18060
18257
|
if (token.type === "variable") {
|
|
18061
18258
|
if (token.scope === "variable" && token.name) onRef(token.name);
|
|
18062
|
-
} else if (token.type === "
|
|
18259
|
+
} else if (token.type === "link") scanInterpolations(token.display, onRef);
|
|
18260
|
+
else if (token.type === "expression") scanCode(token.expression, onRef);
|
|
18063
18261
|
else if (token.type === "html") for (const [name, value] of Object.entries(token.attributes)) if (isCodeAttribute(name)) scanSigilReferences(value, onRef);
|
|
18064
18262
|
else scanInterpolations(value, onRef, storeVarMacros);
|
|
18065
18263
|
else if (token.type === "macro" && !token.isClose) {
|
|
18066
18264
|
scanArgs(token, parametersOf(token.name), onRef);
|
|
18265
|
+
if (token.className) scanInterpolations(token.className, onRef);
|
|
18266
|
+
if (token.id) scanInterpolations(token.id, onRef);
|
|
18067
18267
|
if (storeVarMacros.has(token.name.toLowerCase())) {
|
|
18068
18268
|
const first = token.rawArgs.trim().split(/\s+/)[0] ?? "";
|
|
18069
18269
|
const quoted = QUOTED_VAR_ARG_RE.exec(first);
|
|
18070
18270
|
if (quoted) onRef(quoted[1]);
|
|
18071
18271
|
else if (!/^["'`]/.test(first)) scanCode(first, onRef);
|
|
18072
18272
|
}
|
|
18073
|
-
if (token.name === "do") {
|
|
18273
|
+
if (token.name.toLowerCase() === "do") {
|
|
18074
18274
|
let close = t + 1;
|
|
18075
18275
|
while (close < tokens.length) {
|
|
18076
18276
|
const c = tokens[close];
|
|
18077
|
-
if (c.type === "macro" && c.isClose && c.name === "do") break;
|
|
18277
|
+
if (c.type === "macro" && c.isClose && c.name.toLowerCase() === "do") break;
|
|
18078
18278
|
close++;
|
|
18079
18279
|
}
|
|
18080
18280
|
if (close < tokens.length) {
|
|
@@ -18913,7 +19113,7 @@ defineMacro({
|
|
|
18913
19113
|
visibleCharsRef.current = visibleChars;
|
|
18914
19114
|
useEffect(() => {
|
|
18915
19115
|
if (containerRef.current) setTotalChars((containerRef.current.textContent || "").length);
|
|
18916
|
-
}
|
|
19116
|
+
});
|
|
18917
19117
|
useEffect(() => {
|
|
18918
19118
|
if (totalChars === 0) return;
|
|
18919
19119
|
if (visibleCharsRef.current >= totalChars) return;
|
|
@@ -10040,7 +10040,7 @@ function unset(param) {
|
|
|
10040
10040
|
return param.type === "options" ? {} : void 0;
|
|
10041
10041
|
}
|
|
10042
10042
|
var WORD_RE = /\w+/y;
|
|
10043
|
-
var
|
|
10043
|
+
var NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
|
|
10044
10044
|
var WORD_OR_PUNCT_RE = /\S+/y;
|
|
10045
10045
|
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
10046
10046
|
function matchAt(re, src, pos) {
|
|
@@ -10049,7 +10049,8 @@ function matchAt(re, src, pos) {
|
|
|
10049
10049
|
}
|
|
10050
10050
|
/**
|
|
10051
10051
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
10052
|
-
* quoted string (with or without whitespace before it) or
|
|
10052
|
+
* quoted string (with or without whitespace before it) or number (signed,
|
|
10053
|
+
* fractional) after
|
|
10053
10054
|
* it as its value; keywords that aren't declared, and their values, are
|
|
10054
10055
|
* skipped. A keyword of a `string` parameter must be followed by a quoted
|
|
10055
10056
|
* string.
|
|
@@ -10068,7 +10069,7 @@ function readOptions(params, src, { at, spans }) {
|
|
|
10068
10069
|
let j = i;
|
|
10069
10070
|
while (j < src.length && isWhitespace(src[j])) j++;
|
|
10070
10071
|
const quoted = readQuoted(src, j);
|
|
10071
|
-
const digits = quoted || j === i ? null : matchAt(
|
|
10072
|
+
const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
|
|
10072
10073
|
if (quoted) {
|
|
10073
10074
|
val = quoted.value;
|
|
10074
10075
|
i = quoted.end;
|
|
@@ -10392,15 +10393,22 @@ function* codeAndText(src, tokens, parametersOf) {
|
|
|
10392
10393
|
const closeOf = (t, name) => tokens.findIndex((c, k) => k > t && c.type === "macro" && c.isClose && c.name.toLowerCase() === name);
|
|
10393
10394
|
for (let t = 0; t < tokens.length; t++) {
|
|
10394
10395
|
const token = tokens[t];
|
|
10395
|
-
if (token.type === "link")
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
|
|
10403
|
-
|
|
10396
|
+
if (token.type === "link") {
|
|
10397
|
+
yield {
|
|
10398
|
+
kind: "passage",
|
|
10399
|
+
name: token.target,
|
|
10400
|
+
offset: token.targetStart,
|
|
10401
|
+
length: token.targetEnd - token.targetStart,
|
|
10402
|
+
macro: "link",
|
|
10403
|
+
label: src.slice(token.start, token.end)
|
|
10404
|
+
};
|
|
10405
|
+
if (token.display.includes("{")) yield {
|
|
10406
|
+
kind: "text",
|
|
10407
|
+
text: token.display,
|
|
10408
|
+
offset: locate(src, token.display, token.start),
|
|
10409
|
+
where: "In the label of a link: "
|
|
10410
|
+
};
|
|
10411
|
+
} else if (token.type === "expression") yield {
|
|
10404
10412
|
kind: "code",
|
|
10405
10413
|
code: token.expression,
|
|
10406
10414
|
offset: token.end - 1 - token.expression.length,
|
package/package.json
CHANGED
package/src/code-check.ts
CHANGED
|
@@ -249,6 +249,15 @@ export function* codeAndText(
|
|
|
249
249
|
macro: 'link',
|
|
250
250
|
label: src.slice(token.start, token.end),
|
|
251
251
|
};
|
|
252
|
+
// The label is markup too (the link renders as `{link}`)
|
|
253
|
+
if (token.display.includes('{')) {
|
|
254
|
+
yield {
|
|
255
|
+
kind: 'text',
|
|
256
|
+
text: token.display,
|
|
257
|
+
offset: locate(src, token.display, token.start),
|
|
258
|
+
where: 'In the label of a link: ',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
252
261
|
} else if (token.type === 'expression') {
|
|
253
262
|
yield {
|
|
254
263
|
kind: 'code',
|
|
@@ -5,6 +5,7 @@ import { renderNodes, NobrContext } from '../markup/render';
|
|
|
5
5
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
6
6
|
import { emitFromRender } from '../event-emitter';
|
|
7
7
|
import { useModalFocus } from '../hooks/use-modal-focus';
|
|
8
|
+
import { registerOpenDialog } from '../triggers';
|
|
8
9
|
import { errorMessage } from '../utils/error-message';
|
|
9
10
|
|
|
10
11
|
export const DialogCloseContext = createContext<(() => void) | null>(null);
|
|
@@ -70,6 +71,9 @@ export function PassageDialog({
|
|
|
70
71
|
// Declared before the dialogrender effect so handlers can move focus.
|
|
71
72
|
useModalFocus(panelRef, '.dialog-body', dismissible, stableOnClose);
|
|
72
73
|
|
|
74
|
+
// Be on the stack of displayed dialogs the Story dialog API works on.
|
|
75
|
+
useLayoutEffect(() => registerOpenDialog(stableOnClose), [stableOnClose]);
|
|
76
|
+
|
|
73
77
|
// Signal that the dialog's DOM is committed (once per open).
|
|
74
78
|
useLayoutEffect(() => {
|
|
75
79
|
if (panelRef.current) {
|
|
@@ -15,12 +15,14 @@ defineMacro({
|
|
|
15
15
|
const visibleCharsRef = useRef(0);
|
|
16
16
|
visibleCharsRef.current = visibleChars;
|
|
17
17
|
|
|
18
|
+
// Measure after every render: reactive content can become nonempty (or
|
|
19
|
+
// change length) after mounting. Setting an equal count is a no-op.
|
|
18
20
|
useEffect(() => {
|
|
19
21
|
if (containerRef.current) {
|
|
20
22
|
const text = containerRef.current.textContent || '';
|
|
21
23
|
setTotalChars(text.length);
|
|
22
24
|
}
|
|
23
|
-
}
|
|
25
|
+
});
|
|
24
26
|
|
|
25
27
|
// Typewriter interval
|
|
26
28
|
useEffect(() => {
|
|
@@ -108,7 +108,7 @@ function unset(param: ParameterDef): unknown {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
const WORD_RE = /\w+/y;
|
|
111
|
-
const
|
|
111
|
+
const NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)/y;
|
|
112
112
|
const WORD_OR_PUNCT_RE = /\S+/y;
|
|
113
113
|
|
|
114
114
|
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
@@ -119,7 +119,8 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
121
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
122
|
-
* quoted string (with or without whitespace before it) or
|
|
122
|
+
* quoted string (with or without whitespace before it) or number (signed,
|
|
123
|
+
* fractional) after
|
|
123
124
|
* it as its value; keywords that aren't declared, and their values, are
|
|
124
125
|
* skipped. A keyword of a `string` parameter must be followed by a quoted
|
|
125
126
|
* string.
|
|
@@ -141,13 +142,13 @@ function readOptions(
|
|
|
141
142
|
i += key.length;
|
|
142
143
|
|
|
143
144
|
// A value is a quoted string, which may follow the keyword directly
|
|
144
|
-
// (`goto"Hall"`: a quote can't be part of one), or a
|
|
145
|
+
// (`goto"Hall"`: a quote can't be part of one), or a number after
|
|
145
146
|
// whitespace.
|
|
146
147
|
let val: string | undefined;
|
|
147
148
|
let j = i;
|
|
148
149
|
while (j < src.length && isWhitespace(src[j]!)) j++;
|
|
149
150
|
const quoted = readQuoted(src, j);
|
|
150
|
-
const digits = quoted || j === i ? null : matchAt(
|
|
151
|
+
const digits = quoted || j === i ? null : matchAt(NUMBER_RE, src, j);
|
|
151
152
|
if (quoted) {
|
|
152
153
|
val = quoted.value;
|
|
153
154
|
i = quoted.end;
|