@rohal12/spindle 0.59.9 → 0.59.10
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 +126 -38
- package/package.json +1 -1
- package/src/components/macros/PassageDisplay.tsx +56 -10
- package/src/components/macros/WidgetInvocation.tsx +20 -1
- package/src/components/macros/detached-body.tsx +22 -16
- package/src/components/macros/input-macro.ts +18 -3
- package/src/hooks/use-merged-locals.ts +5 -2
- package/src/hooks/use-story-fields.ts +16 -1
- package/src/markup/render.tsx +9 -4
- package/src/saves/save-manager.ts +47 -3
- package/src/store.ts +17 -7
package/dist/pkg/headless.js
CHANGED
|
@@ -9735,13 +9735,24 @@ function tokenizeMarkup(source, options = {}) {
|
|
|
9735
9735
|
//#endregion
|
|
9736
9736
|
//#region src/hooks/use-story-fields.ts
|
|
9737
9737
|
/**
|
|
9738
|
+
* Tells the components below when to keep the state they last rendered with,
|
|
9739
|
+
* given the store's current state: an owner (a click body, see
|
|
9740
|
+
* useDetachedBody; a passage on its way out, see PassageDisplay) provides it
|
|
9741
|
+
* so that a branch skipped earlier cannot run against later state (#351,
|
|
9742
|
+
* #352). It runs inside the store's own notification, before the owner could
|
|
9743
|
+
* re-render, which is why it is a predicate and not a flag.
|
|
9744
|
+
*/
|
|
9745
|
+
var FrozenStateContext = createContext(null);
|
|
9746
|
+
/**
|
|
9738
9747
|
* Story state fields `keys`, re-rendering when any of them changes (as one
|
|
9739
9748
|
* useStoryStore() selector per field would). The result keeps its identity
|
|
9740
9749
|
* while the fields don't change, as the store's snapshot must.
|
|
9741
9750
|
*/
|
|
9742
9751
|
function useStoryFields(...keys) {
|
|
9743
9752
|
const prev = useRef();
|
|
9753
|
+
const freeze = useContext(FrozenStateContext);
|
|
9744
9754
|
return useStoryStore((s) => {
|
|
9755
|
+
if (prev.current && freeze?.(s)) return prev.current;
|
|
9745
9756
|
const next = {};
|
|
9746
9757
|
for (const key of keys) next[key] = s[key];
|
|
9747
9758
|
return shallow(prev.current, next) ? prev.current : prev.current = next;
|
|
@@ -9754,7 +9765,7 @@ function useStoryFields(...keys) {
|
|
|
9754
9765
|
* All four dicts use unprefixed keys suitable for evaluate/execute.
|
|
9755
9766
|
*/
|
|
9756
9767
|
function useMergedLocals() {
|
|
9757
|
-
const { variables, temporary, transient } = useStoryFields("variables", "temporary", "transient");
|
|
9768
|
+
const { variables, temporary, transient, renderCounts } = useStoryFields("variables", "temporary", "transient", "renderCounts");
|
|
9758
9769
|
const localsValues = useContext(LocalsValuesContext);
|
|
9759
9770
|
return useMemo(() => {
|
|
9760
9771
|
return [
|
|
@@ -9767,7 +9778,8 @@ function useMergedLocals() {
|
|
|
9767
9778
|
variables,
|
|
9768
9779
|
temporary,
|
|
9769
9780
|
localsValues,
|
|
9770
|
-
transient
|
|
9781
|
+
transient,
|
|
9782
|
+
renderCounts
|
|
9771
9783
|
]);
|
|
9772
9784
|
}
|
|
9773
9785
|
//#endregion
|
|
@@ -10887,7 +10899,17 @@ function LocalsScope({ body, parentValues, ownKeys }) {
|
|
|
10887
10899
|
}
|
|
10888
10900
|
//#endregion
|
|
10889
10901
|
//#region src/components/macros/WidgetInvocation.tsx
|
|
10890
|
-
function WidgetInvocation(
|
|
10902
|
+
function WidgetInvocation(props) {
|
|
10903
|
+
const { className, id } = props;
|
|
10904
|
+
const content = /* @__PURE__ */ jsx(WidgetContent, { ...props });
|
|
10905
|
+
if (!className && !id) return content;
|
|
10906
|
+
return /* @__PURE__ */ jsx("span", {
|
|
10907
|
+
class: className,
|
|
10908
|
+
id,
|
|
10909
|
+
children: content
|
|
10910
|
+
});
|
|
10911
|
+
}
|
|
10912
|
+
function WidgetContent({ body, params, rawArgs, invocationChildren, className, id }) {
|
|
10891
10913
|
const renderOptions = useRenderOptions();
|
|
10892
10914
|
const parentValues = renderOptions.locals;
|
|
10893
10915
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = useMergedLocals();
|
|
@@ -11643,18 +11665,21 @@ function nodeKey(node) {
|
|
|
11643
11665
|
}
|
|
11644
11666
|
function renderMacro(node, key) {
|
|
11645
11667
|
if (isSubMacro(node.name)) return null;
|
|
11668
|
+
const invocation = {
|
|
11669
|
+
rawArgs: node.rawArgs,
|
|
11670
|
+
className: node.className,
|
|
11671
|
+
id: node.id
|
|
11672
|
+
};
|
|
11646
11673
|
const widget = getWidget(node.name);
|
|
11647
11674
|
if (widget) return /* @__PURE__ */ jsx(WidgetInvocation, {
|
|
11675
|
+
...invocation,
|
|
11648
11676
|
body: widget.body,
|
|
11649
11677
|
params: widget.params,
|
|
11650
|
-
rawArgs: node.rawArgs,
|
|
11651
11678
|
invocationChildren: node.children
|
|
11652
11679
|
}, key);
|
|
11653
11680
|
const Component = getMacro(node.name);
|
|
11654
11681
|
if (Component) return /* @__PURE__ */ jsx(Component, {
|
|
11655
|
-
|
|
11656
|
-
className: node.className,
|
|
11657
|
-
id: node.id,
|
|
11682
|
+
...invocation,
|
|
11658
11683
|
children: node.children,
|
|
11659
11684
|
branches: node.branches
|
|
11660
11685
|
}, key);
|
|
@@ -14177,6 +14202,7 @@ async function startNewPlaythroughNow(ifid, id = randomUUID()) {
|
|
|
14177
14202
|
await backend.setMeta(playthroughCountKey(ifid), num);
|
|
14178
14203
|
});
|
|
14179
14204
|
await backend.setMeta(currentPlaythroughKey(ifid), id);
|
|
14205
|
+
saveSessionPlaythrough(ifid, id);
|
|
14180
14206
|
return id;
|
|
14181
14207
|
}
|
|
14182
14208
|
var PLAYTHROUGH_LABEL = /^Playthrough (\d+)$/;
|
|
@@ -14216,6 +14242,7 @@ async function adoptPlaythroughNow(ifid, id) {
|
|
|
14216
14242
|
await ensurePlaythrough(ifid, id, (/* @__PURE__ */ new Date()).toISOString());
|
|
14217
14243
|
const key = currentPlaythroughKey(ifid);
|
|
14218
14244
|
if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
|
|
14245
|
+
saveSessionPlaythrough(ifid, id);
|
|
14219
14246
|
}
|
|
14220
14247
|
/**
|
|
14221
14248
|
* Record the playthrough `id` of the story as "Imported", created at
|
|
@@ -14238,8 +14265,12 @@ async function ensurePlaythrough(ifid, id, createdAt) {
|
|
|
14238
14265
|
*/
|
|
14239
14266
|
function establishPlaythrough(ifid) {
|
|
14240
14267
|
return inOrder(async () => {
|
|
14268
|
+
const backend = await getBackend();
|
|
14269
|
+
const own = loadSessionPlaythrough(ifid);
|
|
14270
|
+
const id = own !== void 0 && (await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === own) ? own : await backend.getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid);
|
|
14271
|
+
saveSessionPlaythrough(ifid, id);
|
|
14241
14272
|
return {
|
|
14242
|
-
id
|
|
14273
|
+
id,
|
|
14243
14274
|
knownSaves: await populateKnownSavesNow(ifid)
|
|
14244
14275
|
};
|
|
14245
14276
|
});
|
|
@@ -14542,12 +14573,16 @@ readingSlot(livePayload);
|
|
|
14542
14573
|
* (see adoptPlaythrough) in the same operation: operations issued after the
|
|
14543
14574
|
* load take effect in the loaded playthrough. Resolves to the live payload
|
|
14544
14575
|
* and its playthrough, or undefined if the slot is empty.
|
|
14576
|
+
*
|
|
14577
|
+
* `check` is run on the payload first: when it throws, the load rejects and
|
|
14578
|
+
* the game stays in its playthrough (#357).
|
|
14545
14579
|
*/
|
|
14546
|
-
function loadSlotSave(ifid, slot) {
|
|
14580
|
+
function loadSlotSave(ifid, slot, check) {
|
|
14547
14581
|
return inOrder(async () => {
|
|
14548
14582
|
const record = await slotRecord(ifid, slot);
|
|
14549
14583
|
if (!record) return void 0;
|
|
14550
14584
|
const payload = decodePayload(record.payload);
|
|
14585
|
+
check?.(payload);
|
|
14551
14586
|
const { playthroughId } = record.meta;
|
|
14552
14587
|
if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
|
|
14553
14588
|
return {
|
|
@@ -14637,6 +14672,26 @@ function clearSession(ifid) {
|
|
|
14637
14672
|
sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${ifid}`);
|
|
14638
14673
|
} catch {}
|
|
14639
14674
|
}
|
|
14675
|
+
/**
|
|
14676
|
+
* The playthrough this tab's game is in, kept with its session: the shared
|
|
14677
|
+
* current playthrough (a stored meta value) can be changed by another tab.
|
|
14678
|
+
* Written where this tab's storage operations set the current playthrough,
|
|
14679
|
+
* so it follows them in call order. Under the session prefix, so clearing
|
|
14680
|
+
* all data clears it.
|
|
14681
|
+
*/
|
|
14682
|
+
var SESSION_PLAYTHROUGH_PREFIX = `${SESSION_KEY_PREFIX}playthrough.`;
|
|
14683
|
+
function saveSessionPlaythrough(ifid, id) {
|
|
14684
|
+
try {
|
|
14685
|
+
sessionStorage.setItem(`${SESSION_PLAYTHROUGH_PREFIX}${ifid}`, id);
|
|
14686
|
+
} catch {}
|
|
14687
|
+
}
|
|
14688
|
+
function loadSessionPlaythrough(ifid) {
|
|
14689
|
+
try {
|
|
14690
|
+
return sessionStorage.getItem(`${SESSION_PLAYTHROUGH_PREFIX}${ifid}`) || void 0;
|
|
14691
|
+
} catch {
|
|
14692
|
+
return;
|
|
14693
|
+
}
|
|
14694
|
+
}
|
|
14640
14695
|
/** The export of a stored save, if there is one. */
|
|
14641
14696
|
function toExport(record) {
|
|
14642
14697
|
if (!record) return void 0;
|
|
@@ -16078,6 +16133,11 @@ function missingPassage(storyData, payload) {
|
|
|
16078
16133
|
if (!storyData) return void 0;
|
|
16079
16134
|
return [payload.passage, ...payload.history.map((m) => m.passage)].find((name) => !storyData.passages.has(name));
|
|
16080
16135
|
}
|
|
16136
|
+
/** Throw if `payload` refers to a passage `storyData` does not have. */
|
|
16137
|
+
function assertPassagesExist(storyData, payload) {
|
|
16138
|
+
const missing = missingPassage(storyData, payload);
|
|
16139
|
+
if (missing !== void 0) throw new Error(`The save refers to the passage "${missing}", which this version of the story does not have`);
|
|
16140
|
+
}
|
|
16081
16141
|
var NAMESPACE_KEYS = [
|
|
16082
16142
|
"variables",
|
|
16083
16143
|
"temporary",
|
|
@@ -16420,7 +16480,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
16420
16480
|
state.loadError = null;
|
|
16421
16481
|
});
|
|
16422
16482
|
const previous = resolvePlaythroughId();
|
|
16423
|
-
const read = loadSlotSave(storyData.ifid, slot);
|
|
16483
|
+
const read = loadSlotSave(storyData.ifid, slot, (payload) => assertPassagesExist(storyData, payload));
|
|
16424
16484
|
const switched = switchToLookedUpPlaythrough(Promise.all([previous, read.catch(() => void 0)]).then(([prev, loaded]) => loaded?.playthroughId || prev));
|
|
16425
16485
|
const replacement = ++stateReplacementsIssued;
|
|
16426
16486
|
return reported(read.then(async (loaded) => {
|
|
@@ -16525,8 +16585,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
|
|
|
16525
16585
|
console.warn("loadFromPayload: rejecting payload with empty history");
|
|
16526
16586
|
return;
|
|
16527
16587
|
}
|
|
16528
|
-
|
|
16529
|
-
if (missing !== void 0) throw new Error(`The save refers to the passage "${missing}", which this version of the story does not have`);
|
|
16588
|
+
assertPassagesExist(get().storyData, payload);
|
|
16530
16589
|
latestStateApplied = replacement;
|
|
16531
16590
|
emit("beforeload", slot);
|
|
16532
16591
|
const ifid = get().storyData?.ifid;
|
|
@@ -19354,11 +19413,13 @@ defineMacro({
|
|
|
19354
19413
|
});
|
|
19355
19414
|
//#endregion
|
|
19356
19415
|
//#region src/components/macros/detached-body.tsx
|
|
19416
|
+
var frozenAlways = () => true;
|
|
19357
19417
|
/**
|
|
19358
19418
|
* Return a function that runs a macro body once, outside the passage tree:
|
|
19359
19419
|
* it renders `children` into a detached node, so the body's macros ({set},
|
|
19360
19420
|
* {if}, {goto}, ...) fire their side effects through the normal Preact
|
|
19361
|
-
* pipeline. Used by {button} and {link} on click. The body
|
|
19421
|
+
* pipeline. Used by {button} and {link} on click. The body's state is frozen
|
|
19422
|
+
* once it has rendered, so it runs once per click. It stays mounted
|
|
19362
19423
|
* until the owning macro unmounts (its passage changes, say), so work it
|
|
19363
19424
|
* starts in an effect, such as the timer of a {timed} or {repeat}, can
|
|
19364
19425
|
* finish; the passage leaving cancels it.
|
|
@@ -19389,24 +19450,27 @@ function useDetachedBody() {
|
|
|
19389
19450
|
const run = (children) => {
|
|
19390
19451
|
const locals = liveLocalsView(updater.getValues);
|
|
19391
19452
|
const container = document.createElement("div");
|
|
19392
|
-
render(/* @__PURE__ */ jsx(
|
|
19393
|
-
value:
|
|
19394
|
-
children: /* @__PURE__ */ jsx(
|
|
19395
|
-
value:
|
|
19396
|
-
children: /* @__PURE__ */ jsx(
|
|
19397
|
-
value:
|
|
19398
|
-
children: /* @__PURE__ */ jsx(
|
|
19399
|
-
value:
|
|
19400
|
-
children: /* @__PURE__ */ jsx(
|
|
19401
|
-
value:
|
|
19402
|
-
children: /* @__PURE__ */ jsx(
|
|
19403
|
-
value:
|
|
19404
|
-
children: /* @__PURE__ */ jsx(
|
|
19405
|
-
value:
|
|
19406
|
-
children:
|
|
19407
|
-
|
|
19408
|
-
|
|
19409
|
-
|
|
19453
|
+
render(/* @__PURE__ */ jsx(FrozenStateContext.Provider, {
|
|
19454
|
+
value: frozenAlways,
|
|
19455
|
+
children: /* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
|
|
19456
|
+
value: updater,
|
|
19457
|
+
children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
|
|
19458
|
+
value: locals,
|
|
19459
|
+
children: /* @__PURE__ */ jsx(NobrContext.Provider, {
|
|
19460
|
+
value: nobr,
|
|
19461
|
+
children: /* @__PURE__ */ jsx(InlineContext.Provider, {
|
|
19462
|
+
value: inline,
|
|
19463
|
+
children: /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
|
|
19464
|
+
value: widgetChildren,
|
|
19465
|
+
children: /* @__PURE__ */ jsx(RepeatContext.Provider, {
|
|
19466
|
+
value: repeat,
|
|
19467
|
+
children: /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
|
|
19468
|
+
value: closeDialog,
|
|
19469
|
+
children: renderNodes(children, {
|
|
19470
|
+
nobr,
|
|
19471
|
+
inline,
|
|
19472
|
+
locals
|
|
19473
|
+
})
|
|
19410
19474
|
})
|
|
19411
19475
|
})
|
|
19412
19476
|
})
|
|
@@ -20433,13 +20497,19 @@ function defineInputMacro(name, type, toValue = (input) => input !== void 0 ? St
|
|
|
20433
20497
|
label: placeholder || ctx.varName,
|
|
20434
20498
|
perform: (v) => ctx.setValue(toValue(v))
|
|
20435
20499
|
});
|
|
20500
|
+
const typed = ctx.hooks.useRef(null);
|
|
20501
|
+
const shown = typed.current !== null && toValue(typed.current) === ctx.value && type === "number" ? typed.current : display(ctx.value);
|
|
20436
20502
|
return h(type ? "input" : "textarea", {
|
|
20437
20503
|
type,
|
|
20438
20504
|
id: ctx.id,
|
|
20439
20505
|
class: ctx.cls,
|
|
20440
|
-
value:
|
|
20506
|
+
value: shown,
|
|
20441
20507
|
placeholder,
|
|
20442
|
-
onInput: (e) =>
|
|
20508
|
+
onInput: (e) => {
|
|
20509
|
+
const text = e.target.value;
|
|
20510
|
+
typed.current = text;
|
|
20511
|
+
ctx.setValue(toValue(text));
|
|
20512
|
+
}
|
|
20443
20513
|
});
|
|
20444
20514
|
}
|
|
20445
20515
|
});
|
|
@@ -20831,6 +20901,9 @@ defineMacro({
|
|
|
20831
20901
|
});
|
|
20832
20902
|
const resolvedTypeRef = useRef("fade");
|
|
20833
20903
|
const prevHistoryLenRef = useRef(0);
|
|
20904
|
+
const displayedId = displayed.id;
|
|
20905
|
+
const isOutgoing = useCallback((state) => state.navigationId !== displayedId, [displayedId]);
|
|
20906
|
+
const lastElement = useRef(null);
|
|
20834
20907
|
const containerRef = useRef(null);
|
|
20835
20908
|
const timeoutsRef = useRef([]);
|
|
20836
20909
|
/** Cancel any in-progress transition. */
|
|
@@ -20899,6 +20972,16 @@ defineMacro({
|
|
|
20899
20972
|
cleanupSnapshots(containerEl);
|
|
20900
20973
|
}, config.duration)];
|
|
20901
20974
|
}, [navigationId]);
|
|
20975
|
+
const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
|
|
20976
|
+
const lastShownId = useRef(shownId);
|
|
20977
|
+
useLayoutEffect(() => {
|
|
20978
|
+
if (shownId === null) return;
|
|
20979
|
+
const previous = lastShownId.current;
|
|
20980
|
+
lastShownId.current = shownId;
|
|
20981
|
+
if (previous === null || previous === shownId) return;
|
|
20982
|
+
const el = containerRef.current?.querySelector(".passage");
|
|
20983
|
+
if (el && el.getBoundingClientRect().top < 0) el.scrollIntoView?.({ block: "start" });
|
|
20984
|
+
}, [shownId]);
|
|
20902
20985
|
const effectivePassage = renderDeferred ? storyData?.passages.get("StoryLoading") ?? null : displayedPassage ? storyData?.passages.get(displayedPassage) : null;
|
|
20903
20986
|
if (!effectivePassage && displayedPassage && !renderDeferred) return /* @__PURE__ */ jsxs("div", {
|
|
20904
20987
|
class: "error",
|
|
@@ -20909,6 +20992,12 @@ defineMacro({
|
|
|
20909
20992
|
]
|
|
20910
20993
|
});
|
|
20911
20994
|
const readyPassage = storyData?.passages.get("PassageReady");
|
|
20995
|
+
const passageElement = !renderDeferred && displayed.id !== navigationId && lastElement.current ? lastElement.current : effectivePassage && /* @__PURE__ */ jsx(Passage, {
|
|
20996
|
+
passage: effectivePassage,
|
|
20997
|
+
dataTransition: renderDeferred ? "none" : resolvedTypeRef.current,
|
|
20998
|
+
navigationId: renderDeferred ? void 0 : displayed.id
|
|
20999
|
+
}, renderDeferred ? "loading" : `nav-${displayed.id}`);
|
|
21000
|
+
lastElement.current = passageElement || null;
|
|
20912
21001
|
return /* @__PURE__ */ jsxs("div", {
|
|
20913
21002
|
id: ctx.id ?? "story",
|
|
20914
21003
|
class: ctx.className ?? "story",
|
|
@@ -20918,11 +21007,10 @@ defineMacro({
|
|
|
20918
21007
|
}, `ready-${navigationId}`), /* @__PURE__ */ jsx("div", {
|
|
20919
21008
|
class: "passage-container",
|
|
20920
21009
|
ref: containerRef,
|
|
20921
|
-
children:
|
|
20922
|
-
|
|
20923
|
-
|
|
20924
|
-
|
|
20925
|
-
}, renderDeferred ? "loading" : `nav-${displayed.id}`)
|
|
21010
|
+
children: passageElement && /* @__PURE__ */ jsx(FrozenStateContext.Provider, {
|
|
21011
|
+
value: isOutgoing,
|
|
21012
|
+
children: passageElement
|
|
21013
|
+
})
|
|
20926
21014
|
})]
|
|
20927
21015
|
});
|
|
20928
21016
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
useRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useCallback,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'preact/hooks';
|
|
8
|
+
import { useStoryStore, type StoryState } from '../../store';
|
|
9
|
+
import {
|
|
10
|
+
useStoryFields,
|
|
11
|
+
FrozenStateContext,
|
|
12
|
+
} from '../../hooks/use-story-fields';
|
|
4
13
|
import { Passage, renderPassageContent } from '../Passage';
|
|
5
14
|
import { defineMacro } from '../../define-macro';
|
|
6
15
|
import { resolveTransition, type ResolvedTransition } from '../../transition';
|
|
@@ -126,6 +135,13 @@ defineMacro({
|
|
|
126
135
|
// Track previous history length to detect restart/load
|
|
127
136
|
const prevHistoryLenRef = useRef(0);
|
|
128
137
|
|
|
138
|
+
const displayedId = displayed.id;
|
|
139
|
+
const isOutgoing = useCallback(
|
|
140
|
+
(state: StoryState) => state.navigationId !== displayedId,
|
|
141
|
+
[displayedId],
|
|
142
|
+
);
|
|
143
|
+
const lastElement = useRef<preact.VNode | null>(null);
|
|
144
|
+
|
|
129
145
|
// Container ref for snapshot insertion
|
|
130
146
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
131
147
|
|
|
@@ -265,6 +281,22 @@ defineMacro({
|
|
|
265
281
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
266
282
|
}, [navigationId]);
|
|
267
283
|
|
|
284
|
+
// A new passage opens at its beginning, not where the previous one was
|
|
285
|
+
// scrolled to (#354). The page keeps its position on first display
|
|
286
|
+
// (a refresh restores it) and when the beginning is already in view.
|
|
287
|
+
const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
|
|
288
|
+
const lastShownId = useRef(shownId);
|
|
289
|
+
useLayoutEffect(() => {
|
|
290
|
+
if (shownId === null) return; // between passages of a transition
|
|
291
|
+
const previous = lastShownId.current;
|
|
292
|
+
lastShownId.current = shownId;
|
|
293
|
+
if (previous === null || previous === shownId) return;
|
|
294
|
+
const el = containerRef.current?.querySelector('.passage');
|
|
295
|
+
if (el && el.getBoundingClientRect().top < 0) {
|
|
296
|
+
el.scrollIntoView?.({ block: 'start' });
|
|
297
|
+
}
|
|
298
|
+
}, [shownId]);
|
|
299
|
+
|
|
268
300
|
// When render is deferred, show StoryLoading passage or nothing
|
|
269
301
|
const effectivePassage = renderDeferred
|
|
270
302
|
? (storyData?.passages.get('StoryLoading') ?? null)
|
|
@@ -282,6 +314,23 @@ defineMacro({
|
|
|
282
314
|
|
|
283
315
|
const readyPassage = storyData?.passages.get('PassageReady');
|
|
284
316
|
|
|
317
|
+
// Between a navigation and the commit of its passage, the old passage is
|
|
318
|
+
// still mounted. It must not execute against the destination's state, so
|
|
319
|
+
// it keeps the element and the state it last rendered with (#352).
|
|
320
|
+
const outgoing = !renderDeferred && displayed.id !== navigationId;
|
|
321
|
+
const passageElement =
|
|
322
|
+
outgoing && lastElement.current
|
|
323
|
+
? lastElement.current
|
|
324
|
+
: effectivePassage && (
|
|
325
|
+
<Passage
|
|
326
|
+
passage={effectivePassage}
|
|
327
|
+
key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
|
|
328
|
+
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
329
|
+
navigationId={renderDeferred ? undefined : displayed.id}
|
|
330
|
+
/>
|
|
331
|
+
);
|
|
332
|
+
lastElement.current = passageElement || null;
|
|
333
|
+
|
|
285
334
|
return (
|
|
286
335
|
<div
|
|
287
336
|
id={ctx.id ?? 'story'}
|
|
@@ -299,13 +348,10 @@ defineMacro({
|
|
|
299
348
|
class="passage-container"
|
|
300
349
|
ref={containerRef}
|
|
301
350
|
>
|
|
302
|
-
{
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
307
|
-
navigationId={renderDeferred ? undefined : displayed.id}
|
|
308
|
-
/>
|
|
351
|
+
{passageElement && (
|
|
352
|
+
<FrozenStateContext.Provider value={isOutgoing}>
|
|
353
|
+
{passageElement}
|
|
354
|
+
</FrozenStateContext.Provider>
|
|
309
355
|
)}
|
|
310
356
|
</div>
|
|
311
357
|
</div>
|
|
@@ -15,13 +15,32 @@ interface WidgetInvocationProps {
|
|
|
15
15
|
params: string[];
|
|
16
16
|
rawArgs?: string;
|
|
17
17
|
invocationChildren?: ASTNode[];
|
|
18
|
+
className?: string;
|
|
19
|
+
id?: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function WidgetInvocation({
|
|
22
|
+
export function WidgetInvocation(props: WidgetInvocationProps) {
|
|
23
|
+
const { className, id } = props;
|
|
24
|
+
const content = <WidgetContent {...props} />;
|
|
25
|
+
// Invocation selectors (`{.badge#hero Stats}`) need an element to land on.
|
|
26
|
+
if (!className && !id) return content;
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
class={className}
|
|
30
|
+
id={id}
|
|
31
|
+
>
|
|
32
|
+
{content}
|
|
33
|
+
</span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function WidgetContent({
|
|
21
38
|
body,
|
|
22
39
|
params,
|
|
23
40
|
rawArgs,
|
|
24
41
|
invocationChildren,
|
|
42
|
+
className,
|
|
43
|
+
id,
|
|
25
44
|
}: WidgetInvocationProps) {
|
|
26
45
|
const renderOptions = useRenderOptions();
|
|
27
46
|
const parentValues = renderOptions.locals;
|
|
@@ -10,16 +10,20 @@ import {
|
|
|
10
10
|
} from '../../markup/render';
|
|
11
11
|
import type { ASTNode } from '../../markup/ast';
|
|
12
12
|
import { useRenderOptions } from '../../hooks/use-render-options';
|
|
13
|
+
import { FrozenStateContext } from '../../hooks/use-story-fields';
|
|
13
14
|
import { liveLocalsView } from '../../utils/live-locals';
|
|
14
15
|
import { runWithCommittedMutations } from '../../execute-mutation';
|
|
15
16
|
import { RepeatContext } from './Repeat';
|
|
16
17
|
import { DialogCloseContext } from '../PassageDialog';
|
|
17
18
|
|
|
19
|
+
const frozenAlways = () => true;
|
|
20
|
+
|
|
18
21
|
/**
|
|
19
22
|
* Return a function that runs a macro body once, outside the passage tree:
|
|
20
23
|
* it renders `children` into a detached node, so the body's macros ({set},
|
|
21
24
|
* {if}, {goto}, ...) fire their side effects through the normal Preact
|
|
22
|
-
* pipeline. Used by {button} and {link} on click. The body
|
|
25
|
+
* pipeline. Used by {button} and {link} on click. The body's state is frozen
|
|
26
|
+
* once it has rendered, so it runs once per click. It stays mounted
|
|
23
27
|
* until the owning macro unmounts (its passage changes, say), so work it
|
|
24
28
|
* starts in an effect, such as the timer of a {timed} or {repeat}, can
|
|
25
29
|
* finish; the passage leaving cancels it.
|
|
@@ -58,21 +62,23 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
58
62
|
const locals = liveLocalsView(updater.getValues);
|
|
59
63
|
const container = document.createElement('div');
|
|
60
64
|
render(
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
65
|
+
<FrozenStateContext.Provider value={frozenAlways}>
|
|
66
|
+
<LocalsUpdateContext.Provider value={updater}>
|
|
67
|
+
<LocalsValuesContext.Provider value={locals}>
|
|
68
|
+
<NobrContext.Provider value={nobr}>
|
|
69
|
+
<InlineContext.Provider value={inline}>
|
|
70
|
+
<WidgetChildrenContext.Provider value={widgetChildren}>
|
|
71
|
+
<RepeatContext.Provider value={repeat}>
|
|
72
|
+
<DialogCloseContext.Provider value={closeDialog}>
|
|
73
|
+
{renderNodes(children, { nobr, inline, locals })}
|
|
74
|
+
</DialogCloseContext.Provider>
|
|
75
|
+
</RepeatContext.Provider>
|
|
76
|
+
</WidgetChildrenContext.Provider>
|
|
77
|
+
</InlineContext.Provider>
|
|
78
|
+
</NobrContext.Provider>
|
|
79
|
+
</LocalsValuesContext.Provider>
|
|
80
|
+
</LocalsUpdateContext.Provider>
|
|
81
|
+
</FrozenStateContext.Provider>,
|
|
76
82
|
container,
|
|
77
83
|
);
|
|
78
84
|
mounted.current.push(container);
|
|
@@ -59,14 +59,29 @@ export function defineInputMacro(
|
|
|
59
59
|
perform: (v) => ctx.setValue!(toValue(v)),
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
// What the reader last typed. A number input reports an unfinished
|
|
63
|
+
// number ("-", "2e") as an empty value, so the text is kept for as
|
|
64
|
+
// long as it still converts to the variable's value, instead of being
|
|
65
|
+
// replaced by that value on every keystroke (#353).
|
|
66
|
+
const typed = ctx.hooks.useRef<string | null>(null);
|
|
67
|
+
const shown =
|
|
68
|
+
typed.current !== null &&
|
|
69
|
+
toValue(typed.current) === ctx.value &&
|
|
70
|
+
type === 'number'
|
|
71
|
+
? typed.current
|
|
72
|
+
: display(ctx.value);
|
|
73
|
+
|
|
62
74
|
return h(type ? 'input' : 'textarea', {
|
|
63
75
|
type,
|
|
64
76
|
id: ctx.id,
|
|
65
77
|
class: ctx.cls,
|
|
66
|
-
value:
|
|
78
|
+
value: shown,
|
|
67
79
|
placeholder,
|
|
68
|
-
onInput: (e: Event) =>
|
|
69
|
-
|
|
80
|
+
onInput: (e: Event) => {
|
|
81
|
+
const text = (e.target as HTMLInputElement).value;
|
|
82
|
+
typed.current = text;
|
|
83
|
+
ctx.setValue!(toValue(text));
|
|
84
|
+
},
|
|
70
85
|
});
|
|
71
86
|
},
|
|
72
87
|
});
|
|
@@ -12,14 +12,17 @@ export function useMergedLocals(): readonly [
|
|
|
12
12
|
Record<string, unknown>,
|
|
13
13
|
Record<string, unknown>,
|
|
14
14
|
] {
|
|
15
|
-
|
|
15
|
+
// renderCounts is read by rendered()/hasRendered() inside expressions, so
|
|
16
|
+
// it re-renders them (and renews the tuple) when an inclusion mounts.
|
|
17
|
+
const { variables, temporary, transient, renderCounts } = useStoryFields(
|
|
16
18
|
'variables',
|
|
17
19
|
'temporary',
|
|
18
20
|
'transient',
|
|
21
|
+
'renderCounts',
|
|
19
22
|
);
|
|
20
23
|
const localsValues = useContext(LocalsValuesContext);
|
|
21
24
|
|
|
22
25
|
return useMemo(() => {
|
|
23
26
|
return [variables, temporary, localsValues, transient] as const;
|
|
24
|
-
}, [variables, temporary, localsValues, transient]);
|
|
27
|
+
}, [variables, temporary, localsValues, transient, renderCounts]);
|
|
25
28
|
}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createContext } from 'preact';
|
|
2
|
+
import { useContext, useRef } from 'preact/hooks';
|
|
2
3
|
import { shallow } from 'zustand/vanilla/shallow';
|
|
3
4
|
import { useStoryStore, type StoryState } from '../store';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Tells the components below when to keep the state they last rendered with,
|
|
8
|
+
* given the store's current state: an owner (a click body, see
|
|
9
|
+
* useDetachedBody; a passage on its way out, see PassageDisplay) provides it
|
|
10
|
+
* so that a branch skipped earlier cannot run against later state (#351,
|
|
11
|
+
* #352). It runs inside the store's own notification, before the owner could
|
|
12
|
+
* re-render, which is why it is a predicate and not a flag.
|
|
13
|
+
*/
|
|
14
|
+
export const FrozenStateContext = createContext<
|
|
15
|
+
((state: StoryState) => boolean) | null
|
|
16
|
+
>(null);
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
19
|
* Story state fields `keys`, re-rendering when any of them changes (as one
|
|
7
20
|
* useStoryStore() selector per field would). The result keeps its identity
|
|
@@ -11,7 +24,9 @@ export function useStoryFields<K extends keyof StoryState>(
|
|
|
11
24
|
...keys: K[]
|
|
12
25
|
): Pick<StoryState, K> {
|
|
13
26
|
const prev = useRef<Pick<StoryState, K>>();
|
|
27
|
+
const freeze = useContext(FrozenStateContext);
|
|
14
28
|
return useStoryStore((s) => {
|
|
29
|
+
if (prev.current && freeze?.(s)) return prev.current;
|
|
15
30
|
const next = {} as Pick<StoryState, K>;
|
|
16
31
|
for (const key of keys) next[key] = s[key];
|
|
17
32
|
return shallow(prev.current, next) ? prev.current! : (prev.current = next);
|
package/src/markup/render.tsx
CHANGED
|
@@ -679,14 +679,21 @@ function nodeKey(node: ASTNode): string {
|
|
|
679
679
|
function renderMacro(node: MacroNode, key: string) {
|
|
680
680
|
if (isSubMacro(node.name)) return null;
|
|
681
681
|
|
|
682
|
+
// What an invocation passes on to a widget and to a built-in macro alike
|
|
683
|
+
const invocation = {
|
|
684
|
+
rawArgs: node.rawArgs,
|
|
685
|
+
className: node.className,
|
|
686
|
+
id: node.id,
|
|
687
|
+
};
|
|
688
|
+
|
|
682
689
|
const widget = getWidget(node.name);
|
|
683
690
|
if (widget) {
|
|
684
691
|
return (
|
|
685
692
|
<WidgetInvocation
|
|
686
693
|
key={key}
|
|
694
|
+
{...invocation}
|
|
687
695
|
body={widget.body}
|
|
688
696
|
params={widget.params}
|
|
689
|
-
rawArgs={node.rawArgs}
|
|
690
697
|
invocationChildren={node.children}
|
|
691
698
|
/>
|
|
692
699
|
);
|
|
@@ -697,9 +704,7 @@ function renderMacro(node: MacroNode, key: string) {
|
|
|
697
704
|
return (
|
|
698
705
|
<Component
|
|
699
706
|
key={key}
|
|
700
|
-
|
|
701
|
-
className={node.className}
|
|
702
|
-
id={node.id}
|
|
707
|
+
{...invocation}
|
|
703
708
|
children={node.children}
|
|
704
709
|
branches={node.branches}
|
|
705
710
|
/>
|