@rohal12/spindle 0.52.0 → 0.52.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 -104
- package/package.json +4 -1
- package/src/action-registry.ts +70 -18
- package/src/components/macros/Computed.tsx +13 -5
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Unset.tsx +5 -4
- package/src/execute-mutation.ts +1 -1
- package/src/expression.ts +5 -4
- package/src/hooks/use-action.ts +10 -4
- package/src/index.tsx +2 -1
- package/src/markup/ast.ts +7 -2
- package/src/prng.ts +33 -0
- package/src/saves/save-manager.ts +7 -2
- package/src/settings.ts +12 -9
- package/src/store.ts +119 -32
- package/src/story-api.ts +7 -6
- package/src/triggers.ts +4 -7
- package/src/utils/counts.ts +45 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.52.
|
|
3
|
+
"version": "0.52.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Preact-based story format for Twine 2.",
|
|
6
6
|
"license": "Unlicense",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
57
57
|
"format": "prettier --write .",
|
|
58
58
|
"format:check": "prettier --check .",
|
|
59
|
+
"duplication": "bun scripts/duplication.ts jscpd && bun scripts/duplication.ts cpd && bun scripts/duplication.ts fallow",
|
|
59
60
|
"docs:dev": "vitepress dev docs",
|
|
60
61
|
"docs:build": "vitepress build docs",
|
|
61
62
|
"docs:preview": "vitepress preview docs",
|
|
@@ -82,10 +83,12 @@
|
|
|
82
83
|
"@types/react": "^19.2.14",
|
|
83
84
|
"@vitest/coverage-v8": "^4.1.2",
|
|
84
85
|
"fake-indexeddb": "^6.2.5",
|
|
86
|
+
"fallow": "3.31.0",
|
|
85
87
|
"fast-check": "^4.10.2",
|
|
86
88
|
"happy-dom": "^20.8.9",
|
|
87
89
|
"husky": "^9.1.7",
|
|
88
90
|
"js-yaml": "^4.1.1",
|
|
91
|
+
"jscpd": "5.4.0",
|
|
89
92
|
"lint-staged": "^16.4.0",
|
|
90
93
|
"playwright": "^1.58.2",
|
|
91
94
|
"prettier": "^3.8.1",
|
package/src/action-registry.ts
CHANGED
|
@@ -29,9 +29,35 @@ export interface StoryAction {
|
|
|
29
29
|
perform: (value?: unknown) => void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/** A mounted control's hold on its action ID. */
|
|
33
|
+
export interface ActionRegistration {
|
|
34
|
+
/**
|
|
35
|
+
* Replace what the action exposes, in place: one `actionsChanged`
|
|
36
|
+
* notification, and it keeps its position in getActions().
|
|
37
|
+
*/
|
|
38
|
+
update(action: StoryAction): void;
|
|
39
|
+
/** Remove this registration, and nothing that has taken its ID since. */
|
|
40
|
+
unregister(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Entry {
|
|
44
|
+
action: StoryAction;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The registrations holding each ID, oldest first; the newest is the one
|
|
49
|
+
* listed. Generated IDs don't collide, but author IDs can, and one control's
|
|
50
|
+
* cleanup must not remove another control holding the same ID (#233).
|
|
51
|
+
*/
|
|
52
|
+
const actions = new Map<string, Entry[]>();
|
|
33
53
|
const idCounters = new Map<string, number>();
|
|
34
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The counters reset on every navigation, so a passage's controls get the
|
|
57
|
+
* same IDs each time it is shown. Controls that stay mounted across
|
|
58
|
+
* navigations (e.g. in StoryInterface) keep theirs, so allocation skips every
|
|
59
|
+
* ID still registered (#233).
|
|
60
|
+
*/
|
|
35
61
|
export function generateActionId(
|
|
36
62
|
type: ActionType,
|
|
37
63
|
key: string,
|
|
@@ -40,35 +66,61 @@ export function generateActionId(
|
|
|
40
66
|
if (authorId) return authorId;
|
|
41
67
|
|
|
42
68
|
const base = `${type}:${key}`;
|
|
43
|
-
|
|
69
|
+
let count = idCounters.get(base) ?? 0;
|
|
70
|
+
let id: string;
|
|
71
|
+
do {
|
|
72
|
+
count++;
|
|
73
|
+
id = count === 1 ? base : `${base}:${count}`;
|
|
74
|
+
} while (actions.has(id));
|
|
44
75
|
idCounters.set(base, count);
|
|
45
|
-
return
|
|
76
|
+
return id;
|
|
46
77
|
}
|
|
47
78
|
|
|
48
|
-
export function registerAction(action: StoryAction):
|
|
49
|
-
|
|
79
|
+
export function registerAction(action: StoryAction): ActionRegistration {
|
|
80
|
+
const id = action.id;
|
|
81
|
+
const entry: Entry = { action };
|
|
82
|
+
const entries = actions.get(id);
|
|
83
|
+
if (entries) {
|
|
84
|
+
console.warn(
|
|
85
|
+
`spindle: two mounted controls share the action ID "${id}"; ` +
|
|
86
|
+
'Story.getActions() lists only the newer one.',
|
|
87
|
+
);
|
|
88
|
+
entries.push(entry);
|
|
89
|
+
} else {
|
|
90
|
+
actions.set(id, [entry]);
|
|
91
|
+
}
|
|
50
92
|
notify();
|
|
51
|
-
return () => {
|
|
52
|
-
actions.delete(action.id);
|
|
53
|
-
notify();
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
93
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
return {
|
|
95
|
+
update(next) {
|
|
96
|
+
const current = actions.get(id);
|
|
97
|
+
if (!current?.includes(entry)) return;
|
|
98
|
+
entry.action = next;
|
|
99
|
+
// An older registration of a shared ID isn't listed
|
|
100
|
+
if (current[current.length - 1] === entry) notify();
|
|
101
|
+
},
|
|
102
|
+
unregister() {
|
|
103
|
+
const current = actions.get(id);
|
|
104
|
+
const index = current ? current.indexOf(entry) : -1;
|
|
105
|
+
if (!current || index === -1) return;
|
|
106
|
+
current.splice(index, 1);
|
|
107
|
+
if (current.length === 0) actions.delete(id);
|
|
108
|
+
if (index === current.length) notify(); // it was the listed one
|
|
109
|
+
},
|
|
63
110
|
};
|
|
64
111
|
}
|
|
65
112
|
|
|
66
113
|
export function getActions(): StoryAction[] {
|
|
67
|
-
return Array.from(actions.values());
|
|
114
|
+
return Array.from(actions.values(), listed);
|
|
68
115
|
}
|
|
69
116
|
|
|
70
117
|
export function getAction(id: string): StoryAction | undefined {
|
|
71
|
-
|
|
118
|
+
const entries = actions.get(id);
|
|
119
|
+
return entries && listed(entries);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function listed(entries: Entry[]): StoryAction {
|
|
123
|
+
return entries[entries.length - 1]!.action;
|
|
72
124
|
}
|
|
73
125
|
|
|
74
126
|
export function clearActions(): void {
|
|
@@ -7,6 +7,13 @@ import { defineMacro } from '../../define-macro';
|
|
|
7
7
|
import { MacroError } from './MacroError';
|
|
8
8
|
import { checkVariableName } from '../../utils/namespace';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The previous output before the first successful evaluation. Distinct from
|
|
12
|
+
* every value an expression can produce, so the first result is always
|
|
13
|
+
* applied, even `undefined`; a first evaluation that throws leaves it unset.
|
|
14
|
+
*/
|
|
15
|
+
const UNSET: unique symbol = Symbol('unset');
|
|
16
|
+
|
|
10
17
|
function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
11
18
|
const trimmed = rawArgs.trim();
|
|
12
19
|
|
|
@@ -44,10 +51,11 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
|
|
|
44
51
|
/**
|
|
45
52
|
* Evaluate `expr` against the current values — the store's state and the
|
|
46
53
|
* enclosing locals scope's live values (`getLocals`) — and write the result
|
|
47
|
-
* to the target if it
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
54
|
+
* to the target if it differs from the previous output (always, the first
|
|
55
|
+
* time). Reading live values rather than the render's snapshot means the
|
|
56
|
+
* first computation sees a local a preceding {set} just assigned (the scope's
|
|
57
|
+
* context value only catches up on its re-render), and the first computation
|
|
58
|
+
* and later recomputations read the same source.
|
|
51
59
|
*/
|
|
52
60
|
function computeAndApply(
|
|
53
61
|
expr: string,
|
|
@@ -114,7 +122,7 @@ defineMacro({
|
|
|
114
122
|
const name = target.slice(1);
|
|
115
123
|
const localsUpdate = isLocal ? ctx.update : null;
|
|
116
124
|
|
|
117
|
-
const prevOutput = ctx.hooks.useRef<unknown>(
|
|
125
|
+
const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
|
|
118
126
|
|
|
119
127
|
const ran = ctx.hooks.useRef(false);
|
|
120
128
|
if (!ran.current) {
|
|
@@ -5,7 +5,8 @@ defineMacro({
|
|
|
5
5
|
name: 'set',
|
|
6
6
|
render({ rawArgs }, ctx) {
|
|
7
7
|
const ran = ctx.hooks.useRef(false);
|
|
8
|
-
|
|
8
|
+
// Boxed: anything can be thrown, including null and other falsy values
|
|
9
|
+
const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
|
|
9
10
|
|
|
10
11
|
if (!ran.current) {
|
|
11
12
|
ran.current = true;
|
|
@@ -13,7 +14,7 @@ defineMacro({
|
|
|
13
14
|
try {
|
|
14
15
|
ctx.mutate(rawArgs);
|
|
15
16
|
} catch (err) {
|
|
16
|
-
|
|
17
|
+
failure.current = { error: err };
|
|
17
18
|
console.error(
|
|
18
19
|
`spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`,
|
|
19
20
|
err,
|
|
@@ -21,11 +22,11 @@ defineMacro({
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
if (
|
|
25
|
+
if (failure.current) {
|
|
25
26
|
return (
|
|
26
27
|
<MacroError
|
|
27
28
|
macro="set"
|
|
28
|
-
error={
|
|
29
|
+
error={failure.current.error}
|
|
29
30
|
/>
|
|
30
31
|
);
|
|
31
32
|
}
|
|
@@ -6,7 +6,8 @@ defineMacro({
|
|
|
6
6
|
name: 'unset',
|
|
7
7
|
render({ rawArgs }, ctx) {
|
|
8
8
|
const ran = ctx.hooks.useRef(false);
|
|
9
|
-
|
|
9
|
+
// Boxed: anything can be thrown, including null and other falsy values
|
|
10
|
+
const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
|
|
10
11
|
|
|
11
12
|
if (!ran.current) {
|
|
12
13
|
ran.current = true;
|
|
@@ -29,7 +30,7 @@ defineMacro({
|
|
|
29
30
|
);
|
|
30
31
|
}
|
|
31
32
|
} catch (err) {
|
|
32
|
-
|
|
33
|
+
failure.current = { error: err };
|
|
33
34
|
console.error(
|
|
34
35
|
`spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
|
|
35
36
|
err,
|
|
@@ -37,11 +38,11 @@ defineMacro({
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
if (
|
|
41
|
+
if (failure.current) {
|
|
41
42
|
return (
|
|
42
43
|
<MacroError
|
|
43
44
|
macro="unset"
|
|
44
|
-
error={
|
|
45
|
+
error={failure.current.error}
|
|
45
46
|
/>
|
|
46
47
|
);
|
|
47
48
|
}
|
package/src/execute-mutation.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function frozenCopy<T>(value: T): T {
|
|
|
82
82
|
* replaced as a whole, since their elements have no stable identity to
|
|
83
83
|
* merge by (a shift moves every index).
|
|
84
84
|
*/
|
|
85
|
-
function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
export function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
86
86
|
return (
|
|
87
87
|
typeof value === 'object' &&
|
|
88
88
|
value !== null &&
|
package/src/expression.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isNamespace,
|
|
11
11
|
type Namespace,
|
|
12
12
|
} from './utils/namespace';
|
|
13
|
+
import { countOf, type Counts } from './utils/counts';
|
|
13
14
|
|
|
14
15
|
interface ExpressionFns {
|
|
15
16
|
currentPassage: () => Passage | undefined;
|
|
@@ -116,8 +117,8 @@ function getOrCompile(key: string, body: string): CompiledExpression {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
let cachedFns: ExpressionFns | null = null;
|
|
119
|
-
let cachedVisitCounts:
|
|
120
|
-
let cachedRenderCounts:
|
|
120
|
+
let cachedVisitCounts: Counts | null = null;
|
|
121
|
+
let cachedRenderCounts: Counts | null = null;
|
|
121
122
|
|
|
122
123
|
export function buildExpressionFns() {
|
|
123
124
|
const state = useStoryStore.getState();
|
|
@@ -132,7 +133,7 @@ export function buildExpressionFns() {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
const visited = (name?: string): number =>
|
|
135
|
-
visitCounts
|
|
136
|
+
countOf(visitCounts, name ?? useStoryStore.getState().currentPassage);
|
|
136
137
|
const hasVisited = (name?: string): boolean => visited(name) > 0;
|
|
137
138
|
const hasVisitedAny = (...names: string[]): boolean =>
|
|
138
139
|
names.some((n) => visited(n) > 0);
|
|
@@ -140,7 +141,7 @@ export function buildExpressionFns() {
|
|
|
140
141
|
names.every((n) => visited(n) > 0);
|
|
141
142
|
|
|
142
143
|
const rendered = (name?: string): number =>
|
|
143
|
-
renderCounts
|
|
144
|
+
countOf(renderCounts, name ?? useStoryStore.getState().currentPassage);
|
|
144
145
|
const hasRendered = (name?: string): boolean => rendered(name) > 0;
|
|
145
146
|
const hasRenderedAny = (...names: string[]): boolean =>
|
|
146
147
|
names.some((n) => rendered(n) > 0);
|
package/src/hooks/use-action.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
2
|
import {
|
|
3
3
|
registerAction,
|
|
4
|
-
updateAction,
|
|
5
4
|
generateActionId,
|
|
5
|
+
type ActionRegistration,
|
|
6
6
|
type ActionType,
|
|
7
7
|
type StoryAction,
|
|
8
8
|
} from '../action-registry';
|
|
@@ -47,8 +47,14 @@ export function useAction(opts: UseActionOptions): string {
|
|
|
47
47
|
return action;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
// Registered for the component's lifetime.
|
|
51
|
-
|
|
50
|
+
// Registered for the component's lifetime. Unregistering removes only this
|
|
51
|
+
// registration, never another control's holding the same ID (#233).
|
|
52
|
+
const registration = useRef<ActionRegistration | null>(null);
|
|
53
|
+
useLayoutEffect(() => {
|
|
54
|
+
const own = registerAction(buildAction());
|
|
55
|
+
registration.current = own;
|
|
56
|
+
return () => own.unregister();
|
|
57
|
+
}, [id]);
|
|
52
58
|
|
|
53
59
|
// When what the action exposes changes (e.g. an input's value), replace it
|
|
54
60
|
// in place: one `actionsChanged` notification, and it keeps its position in
|
|
@@ -60,7 +66,7 @@ export function useAction(opts: UseActionOptions): string {
|
|
|
60
66
|
registered.current = true; // the registration above is current
|
|
61
67
|
return;
|
|
62
68
|
}
|
|
63
|
-
|
|
69
|
+
registration.current?.update(buildAction());
|
|
64
70
|
}, [
|
|
65
71
|
opts.type,
|
|
66
72
|
opts.label,
|
package/src/index.tsx
CHANGED
|
@@ -201,7 +201,8 @@ export function boot() {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
// Reset action ID counters on every navigation (the passage remounts even
|
|
204
|
-
// when its name is unchanged)
|
|
204
|
+
// when its name is unchanged). Controls that stay mounted, e.g. in
|
|
205
|
+
// StoryInterface, keep their IDs: allocation skips IDs still registered.
|
|
205
206
|
let prevNavigationId = useStoryStore.getState().navigationId;
|
|
206
207
|
useStoryStore.subscribe((state) => {
|
|
207
208
|
if (state.navigationId !== prevNavigationId) {
|
package/src/markup/ast.ts
CHANGED
|
@@ -242,8 +242,13 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
// Handle branch macros (elseif/else, case/default, next)
|
|
245
|
-
|
|
246
|
-
|
|
245
|
+
const expectedParent = Object.prototype.hasOwnProperty.call(
|
|
246
|
+
BRANCH_PARENT,
|
|
247
|
+
name,
|
|
248
|
+
)
|
|
249
|
+
? BRANCH_PARENT[name]
|
|
250
|
+
: undefined;
|
|
251
|
+
if (expectedParent) {
|
|
247
252
|
const topNode =
|
|
248
253
|
stack.length > 0 ? stack[stack.length - 1]!.node : null;
|
|
249
254
|
if (
|
package/src/prng.ts
CHANGED
|
@@ -40,6 +40,8 @@ let enabled = false;
|
|
|
40
40
|
let currentSeed = '';
|
|
41
41
|
let currentPull = 0;
|
|
42
42
|
let generator: (() => number) | null = null;
|
|
43
|
+
/** Incremented whenever the generator is replaced (init, restore, reset). */
|
|
44
|
+
let generation = 0;
|
|
43
45
|
|
|
44
46
|
// ---------------------------------------------------------------------------
|
|
45
47
|
// Public API
|
|
@@ -71,6 +73,7 @@ export function initPRNG(seed?: string, useEntropy = true): void {
|
|
|
71
73
|
currentPull = 0;
|
|
72
74
|
generator = mulberry32(hashSeed(resolvedSeed));
|
|
73
75
|
enabled = true;
|
|
76
|
+
generation++;
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/**
|
|
@@ -78,6 +81,12 @@ export function initPRNG(seed?: string, useEntropy = true): void {
|
|
|
78
81
|
* Recreates the generator from the seed and fast-forwards to the saved pull count.
|
|
79
82
|
*/
|
|
80
83
|
export function restorePRNG(seed: string, pull: number): void {
|
|
84
|
+
generation++;
|
|
85
|
+
moveTo(seed, pull);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Recreate the generator at `pull` pulls from `seed`. */
|
|
89
|
+
function moveTo(seed: string, pull: number): void {
|
|
81
90
|
currentSeed = seed;
|
|
82
91
|
// Fast-forward in constant time: each pull adds MULBERRY_STEP to the
|
|
83
92
|
// 32-bit state, so `pull` pulls add pull × MULBERRY_STEP (mod 2^32). A
|
|
@@ -90,12 +99,36 @@ export function restorePRNG(seed: string, pull: number): void {
|
|
|
90
99
|
|
|
91
100
|
/** Disable the PRNG (used on restart before StoryInit re-enables it). */
|
|
92
101
|
export function resetPRNG(): void {
|
|
102
|
+
generation++;
|
|
93
103
|
enabled = false;
|
|
94
104
|
currentSeed = '';
|
|
95
105
|
currentPull = 0;
|
|
96
106
|
generator = null;
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Run `fn` without its draws advancing the sequence: the draws it makes are
|
|
111
|
+
* taken back afterwards (also when it throws), so the next draws return the
|
|
112
|
+
* same values again. Save and load event handlers run like this, so that
|
|
113
|
+
* saving or loading never shifts the story's random sequence (a load replays
|
|
114
|
+
* the saved passage from its entry state, without the save's handlers). A
|
|
115
|
+
* generator `fn` replaced (a restart or load it started) is kept.
|
|
116
|
+
*/
|
|
117
|
+
export function withoutDraws<T>(fn: () => T): T {
|
|
118
|
+
const start = { generation, seed: currentSeed, pull: currentPull };
|
|
119
|
+
try {
|
|
120
|
+
return fn();
|
|
121
|
+
} finally {
|
|
122
|
+
if (
|
|
123
|
+
enabled &&
|
|
124
|
+
generation === start.generation &&
|
|
125
|
+
currentPull !== start.pull
|
|
126
|
+
) {
|
|
127
|
+
moveTo(start.seed, start.pull);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
99
132
|
/** Returns the current PRNG state for snapshotting, or null if disabled. */
|
|
100
133
|
export function snapshotPRNG(): PRNGSnapshot | null {
|
|
101
134
|
if (!enabled) return null;
|
|
@@ -11,6 +11,7 @@ import { isSaveExport, isSavePayload } from './types';
|
|
|
11
11
|
import { getBackend, resetBackend } from './storage';
|
|
12
12
|
import { deepClone, serialize, deserialize } from '../class-registry';
|
|
13
13
|
import { emit } from '../event-emitter';
|
|
14
|
+
import { withoutDraws } from '../prng';
|
|
14
15
|
|
|
15
16
|
type TitleGenerator = (payload: SavePayload) => string;
|
|
16
17
|
|
|
@@ -220,6 +221,10 @@ export function establishPlaythrough(
|
|
|
220
221
|
* that captures the payload after them, so the capture can tell which
|
|
221
222
|
* variables the hooks changed (the store's `beginSave`).
|
|
222
223
|
*
|
|
224
|
+
* Random numbers the hooks draw do not advance the story's PRNG (see
|
|
225
|
+
* withoutDraws()): the game goes on with the sequence a load of the save
|
|
226
|
+
* continues with, which replays the saved passage without the hooks.
|
|
227
|
+
*
|
|
223
228
|
* The hook and capture run synchronously, but an error they throw (a failing
|
|
224
229
|
* hook, an unserializable payload) rejects the returned promise like a failed
|
|
225
230
|
* write rather than escaping to the caller.
|
|
@@ -232,10 +237,10 @@ export function saveWithHooks<T>(
|
|
|
232
237
|
): Promise<T> {
|
|
233
238
|
try {
|
|
234
239
|
const capture = beginCapture();
|
|
235
|
-
emit('beforesave', slot, custom);
|
|
240
|
+
withoutDraws(() => emit('beforesave', slot, custom));
|
|
236
241
|
const payload = capture();
|
|
237
242
|
return write(payload).then((result) => {
|
|
238
|
-
emit('aftersave', slot);
|
|
243
|
+
withoutDraws(() => emit('aftersave', slot));
|
|
239
244
|
return result;
|
|
240
245
|
});
|
|
241
246
|
} catch (err) {
|
package/src/settings.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useStoryStore } from './store';
|
|
2
|
+
import { createNamespace, ownValue } from './utils/namespace';
|
|
2
3
|
|
|
3
4
|
export interface ToggleConfig {
|
|
4
5
|
label: string;
|
|
@@ -25,7 +26,9 @@ export type SettingDef =
|
|
|
25
26
|
| { type: 'range'; config: RangeConfig };
|
|
26
27
|
|
|
27
28
|
const definitions = new Map<string, SettingDef>();
|
|
28
|
-
|
|
29
|
+
// No prototype, so a setting may be named `constructor` or `__proto__`:
|
|
30
|
+
// every name is plain storage (see utils/namespace.ts)
|
|
31
|
+
let values: Record<string, unknown> = createNamespace();
|
|
29
32
|
let storageLoaded = false;
|
|
30
33
|
let unsubscribeStoryData: (() => void) | null = null;
|
|
31
34
|
|
|
@@ -65,7 +68,7 @@ function loadFromStorage(): void {
|
|
|
65
68
|
parsed !== null &&
|
|
66
69
|
!Array.isArray(parsed)
|
|
67
70
|
) {
|
|
68
|
-
values =
|
|
71
|
+
values = createNamespace(values, parsed);
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
74
|
} catch {
|
|
@@ -76,7 +79,7 @@ function loadFromStorage(): void {
|
|
|
76
79
|
export const settings = {
|
|
77
80
|
addToggle(name: string, config: ToggleConfig): void {
|
|
78
81
|
definitions.set(name, { type: 'toggle', config });
|
|
79
|
-
if (!(name
|
|
82
|
+
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
80
83
|
values[name] = config.default;
|
|
81
84
|
}
|
|
82
85
|
loadFromStorage();
|
|
@@ -84,7 +87,7 @@ export const settings = {
|
|
|
84
87
|
|
|
85
88
|
addList(name: string, config: ListConfig): void {
|
|
86
89
|
definitions.set(name, { type: 'list', config });
|
|
87
|
-
if (!(name
|
|
90
|
+
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
88
91
|
values[name] = config.default;
|
|
89
92
|
}
|
|
90
93
|
loadFromStorage();
|
|
@@ -92,28 +95,28 @@ export const settings = {
|
|
|
92
95
|
|
|
93
96
|
addRange(name: string, config: RangeConfig): void {
|
|
94
97
|
definitions.set(name, { type: 'range', config });
|
|
95
|
-
if (!(name
|
|
98
|
+
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
96
99
|
values[name] = config.default;
|
|
97
100
|
}
|
|
98
101
|
loadFromStorage();
|
|
99
102
|
},
|
|
100
103
|
|
|
101
104
|
get(name: string): unknown {
|
|
102
|
-
return values
|
|
105
|
+
return ownValue(values, name);
|
|
103
106
|
},
|
|
104
107
|
|
|
105
108
|
getToggle(name: string): boolean {
|
|
106
|
-
const v = values
|
|
109
|
+
const v = ownValue(values, name);
|
|
107
110
|
return typeof v === 'boolean' ? v : false;
|
|
108
111
|
},
|
|
109
112
|
|
|
110
113
|
getList(name: string): string {
|
|
111
|
-
const v = values
|
|
114
|
+
const v = ownValue(values, name);
|
|
112
115
|
return typeof v === 'string' ? v : '';
|
|
113
116
|
},
|
|
114
117
|
|
|
115
118
|
getRange(name: string): number {
|
|
116
|
-
const v = values
|
|
119
|
+
const v = ownValue(values, name);
|
|
117
120
|
return typeof v === 'number' ? v : 0;
|
|
118
121
|
},
|
|
119
122
|
|