@rohal12/spindle 0.59.12 → 0.59.14
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 +416 -94
- package/dist/pkg/story-variables.js +19 -12
- package/package.json +1 -1
- package/src/class-registry.ts +133 -14
- package/src/components/macros/Computed.tsx +23 -3
- package/src/components/macros/For.tsx +6 -3
- package/src/components/macros/Type.tsx +8 -4
- package/src/components/macros/WidgetInvocation.tsx +7 -2
- package/src/define-macro.ts +1 -1
- package/src/execute-mutation.ts +36 -0
- package/src/hooks/use-modal-focus.ts +67 -17
- package/src/interpolation.ts +25 -5
- package/src/markup/render.tsx +39 -7
- package/src/story-api.ts +2 -1
- package/src/story-variables.ts +12 -2
- package/src/structural.ts +85 -21
- package/src/utils/control-edits.ts +36 -3
- package/src/utils/value-kinds.ts +14 -0
- package/src/widgets/widget-def.ts +27 -15
- package/types/tooling.d.ts +7 -1
|
@@ -10431,28 +10431,34 @@ function parseWidgetDef(rawArgs) {
|
|
|
10431
10431
|
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
10432
10432
|
}
|
|
10433
10433
|
/**
|
|
10434
|
-
* A definition: its arguments (read like a {widget} macro's, see
|
|
10435
|
-
* parseWidgetDef) and its body. Macro names are not case-sensitive.
|
|
10436
|
-
*/
|
|
10437
|
-
var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
|
|
10438
|
-
/** `{@children}`, also with selectors such as `{.highlight @children}`. */
|
|
10439
|
-
var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
|
|
10440
|
-
/**
|
|
10441
10434
|
* The names of the block widgets (those whose body renders `{@children}`)
|
|
10442
10435
|
* that StoryInit and the passages tagged `widget` define. They must be
|
|
10443
10436
|
* known as block macros before any passage is parsed, so that passages
|
|
10444
10437
|
* invoking them nest their content whatever the passage order.
|
|
10438
|
+
*
|
|
10439
|
+
* The passages are read as flat tokens, which need no block macros to be
|
|
10440
|
+
* known, so a `{@children}` in an HTML comment or in a `{do}` body is text
|
|
10441
|
+
* here as it is to the parser, and does not make a block widget (#387).
|
|
10445
10442
|
*/
|
|
10446
10443
|
function blockWidgetNames(passages) {
|
|
10447
10444
|
const names = [];
|
|
10448
10445
|
for (const passage of passages) {
|
|
10449
10446
|
if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
|
|
10450
|
-
|
|
10451
|
-
|
|
10447
|
+
const open = [];
|
|
10448
|
+
for (const token of tokenizeMarkupTolerant(passage.content).tokens) if (token.type === "macro" && token.name.toLowerCase() === "widget") {
|
|
10449
|
+
if (!token.isClose) {
|
|
10450
|
+
open.push({
|
|
10451
|
+
rawArgs: token.rawArgs,
|
|
10452
|
+
isBlock: false
|
|
10453
|
+
});
|
|
10454
|
+
continue;
|
|
10455
|
+
}
|
|
10456
|
+
const def = open.pop();
|
|
10457
|
+
if (!def?.isBlock) continue;
|
|
10452
10458
|
try {
|
|
10453
|
-
names.push(parseWidgetDef(
|
|
10459
|
+
names.push(parseWidgetDef(def.rawArgs).name);
|
|
10454
10460
|
} catch {}
|
|
10455
|
-
}
|
|
10461
|
+
} else if (token.type === "variable" && token.scope === "local" && token.name === "children" && open.length > 0) open[open.length - 1].isBlock = true;
|
|
10456
10462
|
}
|
|
10457
10463
|
return names;
|
|
10458
10464
|
}
|
|
@@ -11082,7 +11088,8 @@ var VALID_VAR_TYPES = new Set([
|
|
|
11082
11088
|
]);
|
|
11083
11089
|
function inferSchema(value) {
|
|
11084
11090
|
if (Array.isArray(value)) return { type: "array" };
|
|
11085
|
-
if (value
|
|
11091
|
+
if (value === null) return { type: "null" };
|
|
11092
|
+
if (typeof value === "object") {
|
|
11086
11093
|
const fields = /* @__PURE__ */ new Map();
|
|
11087
11094
|
for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
|
|
11088
11095
|
return {
|
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { parse, stringify } from 'devalue';
|
|
4
4
|
import { hasOwn } from './utils/namespace';
|
|
5
|
+
import { extraKeys } from './utils/value-kinds';
|
|
5
6
|
|
|
6
7
|
type Constructor = new (...args: any[]) => any;
|
|
7
8
|
|
|
@@ -46,7 +47,10 @@ export function clearRegistry(): void {
|
|
|
46
47
|
// values. Spindle adds, with reducers and revivers:
|
|
47
48
|
//
|
|
48
49
|
// - `c:<name>`: an instance of the class registered as <name>, with its own
|
|
49
|
-
// enumerable keys (and, for Error subclasses, its message and cause);
|
|
50
|
+
// enumerable keys (and, for Error subclasses, its message and cause); for
|
|
51
|
+
// a subclass of Array, Map or Set, `[kind, contents, keys]`: the kind of
|
|
52
|
+
// collection, its elements (Map entries as pairs) and its other own
|
|
53
|
+
// enumerable keys;
|
|
50
54
|
// - `E:<name>`: a built-in error (Error, TypeError, ..., AggregateError),
|
|
51
55
|
// with its message, cause, errors and own enumerable keys (not its stack);
|
|
52
56
|
// - `S`: a symbol from the global registry (Symbol.for);
|
|
@@ -103,6 +107,13 @@ const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
|
|
|
103
107
|
const isObject = (v: unknown): v is object =>
|
|
104
108
|
typeof v === 'object' && v !== null;
|
|
105
109
|
|
|
110
|
+
/** Whether `v` is a plain object (as devalue revives one). */
|
|
111
|
+
const isPlainData = (v: unknown): v is Record<string, unknown> =>
|
|
112
|
+
isObject(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
113
|
+
|
|
114
|
+
const malformedClass = (name: string): TypeError =>
|
|
115
|
+
new TypeError(`spindle: Malformed data for class "${name}"`);
|
|
116
|
+
|
|
106
117
|
const defineData = (target: object, key: string, value: unknown): void => {
|
|
107
118
|
// Define, so that a "__proto__" key stays a key (devalue refuses it)
|
|
108
119
|
Object.defineProperty(target, key, {
|
|
@@ -127,7 +138,10 @@ function refuseSymbolKeys(value: object): void {
|
|
|
127
138
|
* Own enumerable keys of `value`, as a plain object; for an error also its
|
|
128
139
|
* message, cause and (AggregateError) errors, which are not enumerable.
|
|
129
140
|
*/
|
|
130
|
-
function ownData(
|
|
141
|
+
function ownData(
|
|
142
|
+
value: object,
|
|
143
|
+
keys = Object.keys(value),
|
|
144
|
+
): Record<string, unknown> {
|
|
131
145
|
refuseSymbolKeys(value);
|
|
132
146
|
const data: Record<string, unknown> = {};
|
|
133
147
|
if (value instanceof Error) {
|
|
@@ -135,12 +149,44 @@ function ownData(value: object): Record<string, unknown> {
|
|
|
135
149
|
if (hasOwn(value, key)) defineData(data, key, value[key as keyof Error]);
|
|
136
150
|
}
|
|
137
151
|
}
|
|
138
|
-
for (const key of
|
|
152
|
+
for (const key of keys) {
|
|
139
153
|
defineData(data, key, (value as Record<string, unknown>)[key]);
|
|
140
154
|
}
|
|
141
155
|
return data;
|
|
142
156
|
}
|
|
143
157
|
|
|
158
|
+
/** The kinds of collection a registered class may extend. */
|
|
159
|
+
const COLLECTION_KINDS = {
|
|
160
|
+
Array: Array as unknown as Constructor,
|
|
161
|
+
Map: Map as Constructor,
|
|
162
|
+
Set: Set as Constructor,
|
|
163
|
+
};
|
|
164
|
+
type CollectionKind = keyof typeof COLLECTION_KINDS;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The saved form of a registered class instance: its own data (see
|
|
168
|
+
* ownData), or for a collection its kind, elements and extra keys, read
|
|
169
|
+
* with the built-in methods, which a subclass may override (#391).
|
|
170
|
+
*/
|
|
171
|
+
function classData(value: object): unknown {
|
|
172
|
+
const extra = () => ownData(value, extraKeys(value));
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
// Holes stay holes
|
|
175
|
+
const items = new Array(value.length) as unknown[];
|
|
176
|
+
for (let i = 0; i < value.length; i++) {
|
|
177
|
+
if (i in value) items[i] = value[i] as unknown;
|
|
178
|
+
}
|
|
179
|
+
return ['Array', items, extra()];
|
|
180
|
+
}
|
|
181
|
+
if (value instanceof Map) {
|
|
182
|
+
return ['Map', Array.from(Map.prototype.entries.call(value)), extra()];
|
|
183
|
+
}
|
|
184
|
+
if (value instanceof Set) {
|
|
185
|
+
return ['Set', Array.from(Set.prototype.values.call(value)), extra()];
|
|
186
|
+
}
|
|
187
|
+
return ownData(value);
|
|
188
|
+
}
|
|
189
|
+
|
|
144
190
|
/** Make an error's message, cause and errors non-enumerable again. */
|
|
145
191
|
function hideErrorKeys(value: object): void {
|
|
146
192
|
for (const key of ERROR_HIDDEN_KEYS) {
|
|
@@ -158,7 +204,9 @@ function reducers(): Record<string, (value: unknown) => unknown> {
|
|
|
158
204
|
const out: Record<string, (value: unknown) => unknown> = {};
|
|
159
205
|
for (const [name, ctor] of registry) {
|
|
160
206
|
out[CLASS_PREFIX + name] = (v) =>
|
|
161
|
-
isObject(v) &&
|
|
207
|
+
isObject(v) &&
|
|
208
|
+
Object.getPrototypeOf(v) === ctor.prototype &&
|
|
209
|
+
classData(v);
|
|
162
210
|
}
|
|
163
211
|
for (const [proto, name] of BUILTIN_ERRORS) {
|
|
164
212
|
out[ERROR_PREFIX + name] = (v) =>
|
|
@@ -175,7 +223,7 @@ function reducers(): Record<string, (value: unknown) => unknown> {
|
|
|
175
223
|
v.lastIndex !== 0 && [v.source, v.flags, v.lastIndex];
|
|
176
224
|
if (GUARD_V8_KEYS) {
|
|
177
225
|
out[KEYS_TAG] = (v) => {
|
|
178
|
-
if (!
|
|
226
|
+
if (!isPlainData(v)) {
|
|
179
227
|
return false;
|
|
180
228
|
}
|
|
181
229
|
const keys = Object.keys(v);
|
|
@@ -192,19 +240,79 @@ function reducers(): Record<string, (value: unknown) => unknown> {
|
|
|
192
240
|
return out;
|
|
193
241
|
}
|
|
194
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The collection of a registered class that a `[kind, contents, keys]`
|
|
245
|
+
* list holds (see classData). A cycle through the collection revives it
|
|
246
|
+
* before its list is complete (devalue holds unread parts as holes), so the
|
|
247
|
+
* collection is kept per list and filled with what the list holds so far.
|
|
248
|
+
*/
|
|
249
|
+
const collections = new WeakMap<unknown[], object>();
|
|
250
|
+
function reviveCollection(name: string, ctor: Constructor, list: unknown[]) {
|
|
251
|
+
const [kind, contents, keys] = list;
|
|
252
|
+
const base =
|
|
253
|
+
typeof kind === 'string' && hasOwn(COLLECTION_KINDS, kind)
|
|
254
|
+
? COLLECTION_KINDS[kind as CollectionKind]
|
|
255
|
+
: undefined;
|
|
256
|
+
if (
|
|
257
|
+
list.length !== 3 ||
|
|
258
|
+
!base ||
|
|
259
|
+
!(ctor.prototype instanceof base) ||
|
|
260
|
+
(hasOwn(list, 1) && !Array.isArray(contents)) ||
|
|
261
|
+
(hasOwn(list, 2) && !isPlainData(keys))
|
|
262
|
+
) {
|
|
263
|
+
throw malformedClass(name);
|
|
264
|
+
}
|
|
265
|
+
let made = collections.get(list);
|
|
266
|
+
if (!made) {
|
|
267
|
+
made = Object.setPrototypeOf(new base(), ctor.prototype) as object;
|
|
268
|
+
collections.set(list, made);
|
|
269
|
+
}
|
|
270
|
+
if (Array.isArray(contents)) fillCollection(made, contents);
|
|
271
|
+
if (isPlainData(keys)) {
|
|
272
|
+
for (const key of Object.keys(keys)) defineData(made, key, keys[key]);
|
|
273
|
+
}
|
|
274
|
+
return made;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Set the elements of a revived collection, with the built-in methods. */
|
|
278
|
+
function fillCollection(made: object, contents: unknown[]): void {
|
|
279
|
+
if (Array.isArray(made)) {
|
|
280
|
+
made.length = contents.length;
|
|
281
|
+
for (let i = 0; i < contents.length; i++) {
|
|
282
|
+
if (hasOwn(contents, i)) made[i] = contents[i];
|
|
283
|
+
else delete made[i];
|
|
284
|
+
}
|
|
285
|
+
} else if (made instanceof Map) {
|
|
286
|
+
Map.prototype.clear.call(made);
|
|
287
|
+
for (const entry of contents) {
|
|
288
|
+
if (!Array.isArray(entry) || entry.length !== 2) {
|
|
289
|
+
throw new TypeError('spindle: Malformed map entries');
|
|
290
|
+
}
|
|
291
|
+
Map.prototype.set.call(made, entry[0], entry[1]);
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
Set.prototype.clear.call(made);
|
|
295
|
+
for (const member of contents) Set.prototype.add.call(made, member);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
195
299
|
/** Restore a registered class instance in place, so cycles through it hold. */
|
|
196
300
|
function reviveClass(name: string) {
|
|
197
|
-
return (data: Record<string, unknown>): unknown => {
|
|
198
|
-
if (
|
|
301
|
+
return (data: Record<string, unknown> | unknown[]): unknown => {
|
|
302
|
+
if (Array.isArray(data)) {
|
|
303
|
+
return reviveCollection(name, registeredCtor(name), data);
|
|
304
|
+
}
|
|
305
|
+
if (!isPlainData(data)) {
|
|
199
306
|
// Already revived: a cycle through the instance revives it twice
|
|
200
307
|
if (isObject(data) && registeredClassName(data) === name) return data;
|
|
201
|
-
throw
|
|
308
|
+
throw malformedClass(name);
|
|
202
309
|
}
|
|
203
|
-
const ctor =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
310
|
+
const ctor = registeredCtor(name);
|
|
311
|
+
// A plain object cannot be made a collection (see classData)
|
|
312
|
+
if (
|
|
313
|
+
Object.values(COLLECTION_KINDS).some((c) => ctor.prototype instanceof c)
|
|
314
|
+
) {
|
|
315
|
+
throw malformedClass(name);
|
|
208
316
|
}
|
|
209
317
|
Object.setPrototypeOf(data, ctor.prototype as object);
|
|
210
318
|
if (data instanceof Error) hideErrorKeys(data);
|
|
@@ -212,13 +320,24 @@ function reviveClass(name: string) {
|
|
|
212
320
|
};
|
|
213
321
|
}
|
|
214
322
|
|
|
323
|
+
/** The class registered as `name`; throws if there is none. */
|
|
324
|
+
function registeredCtor(name: string): Constructor {
|
|
325
|
+
const ctor = registry.get(name);
|
|
326
|
+
if (!ctor) {
|
|
327
|
+
throw new TypeError(
|
|
328
|
+
`spindle: The save holds an instance of class "${name}", which is not registered`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return ctor;
|
|
332
|
+
}
|
|
333
|
+
|
|
215
334
|
/** Restore a built-in error in place, like a class instance. */
|
|
216
335
|
function reviveError(name: string) {
|
|
217
336
|
const ctor = ERROR_CTORS.get(name);
|
|
218
337
|
if (!ctor) return undefined;
|
|
219
338
|
return (data: Record<string, unknown>): unknown => {
|
|
220
339
|
if (Object.getPrototypeOf(data) === ctor.prototype) return data;
|
|
221
|
-
if (!
|
|
340
|
+
if (!isPlainData(data)) {
|
|
222
341
|
throw new TypeError(`spindle: Malformed data for "${name}"`);
|
|
223
342
|
}
|
|
224
343
|
Object.setPrototypeOf(data, ctor.prototype);
|
|
@@ -5,7 +5,7 @@ import { deepEqualStrict } from '../../structural';
|
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
6
|
import { MacroError, logMacroError } from './MacroError';
|
|
7
7
|
import { currentSourceLocation } from '../../utils/source-location';
|
|
8
|
-
import { checkVariableName } from '../../utils/namespace';
|
|
8
|
+
import { checkVariableName, ownValue } from '../../utils/namespace';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The previous output before the first successful evaluation. Distinct from
|
|
@@ -39,7 +39,7 @@ function assignmentArgs(
|
|
|
39
39
|
/**
|
|
40
40
|
* Evaluate `expr` against the current values — the store's state and the
|
|
41
41
|
* enclosing locals scope's live values (`getLocals`) — and write the result
|
|
42
|
-
* to the target if it differs from the previous output (always, the first
|
|
42
|
+
* to the target if it differs from the previous output or the target's value (always, the first
|
|
43
43
|
* time). Reading live values rather than the render's snapshot means the
|
|
44
44
|
* first computation sees a local a preceding {set} just assigned (the scope's
|
|
45
45
|
* context value only catches up on its re-render), and the first computation
|
|
@@ -53,22 +53,39 @@ function computeAndApply(
|
|
|
53
53
|
getLocals: () => Record<string, unknown>,
|
|
54
54
|
rawArgs: string,
|
|
55
55
|
prevRef: { current: unknown },
|
|
56
|
+
lastNavigation: { current: number },
|
|
56
57
|
localsUpdate: ((key: string, value: unknown) => void) | null,
|
|
57
58
|
): void {
|
|
58
59
|
// Before evaluating: the expression may navigate away from this passage
|
|
59
60
|
const location = currentSourceLocation();
|
|
60
61
|
let newValue: unknown;
|
|
62
|
+
let current: unknown = prevRef.current;
|
|
63
|
+
let navigated = false;
|
|
64
|
+
const { navigationId } = useStoryStore.getState();
|
|
61
65
|
try {
|
|
62
66
|
// In program order, also when mutation code sets this off
|
|
63
67
|
const { variables, temporary, transient } = readState();
|
|
68
|
+
// A navigation (or restored session) resets the temporary namespace,
|
|
69
|
+
// which a persistent {computed} survives: the target is then gone though
|
|
70
|
+
// the result is unchanged, and is written again. Between navigations a
|
|
71
|
+
// target the story removed or set itself stays as it is.
|
|
72
|
+
if (!isLocal) {
|
|
73
|
+
current = ownValue(isTemp ? temporary : variables, name);
|
|
74
|
+
navigated = navigationId !== lastNavigation.current;
|
|
75
|
+
}
|
|
64
76
|
newValue = evaluate(expr, variables, temporary, getLocals(), transient);
|
|
65
77
|
} catch (err) {
|
|
66
78
|
logMacroError(`computed ${rawArgs}`, err, location);
|
|
67
79
|
return;
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
lastNavigation.current = navigationId;
|
|
83
|
+
|
|
70
84
|
// Strict: a change of only the aliases inside the result is a change
|
|
71
|
-
if (
|
|
85
|
+
if (
|
|
86
|
+
!deepEqualStrict(prevRef.current, newValue) ||
|
|
87
|
+
(navigated && !deepEqualStrict(current, newValue))
|
|
88
|
+
) {
|
|
72
89
|
prevRef.current = newValue;
|
|
73
90
|
if (isLocal) {
|
|
74
91
|
try {
|
|
@@ -111,6 +128,8 @@ defineMacro({
|
|
|
111
128
|
const localsUpdate = isLocal ? ctx.update : null;
|
|
112
129
|
|
|
113
130
|
const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
|
|
131
|
+
// The navigation the target was last checked in
|
|
132
|
+
const lastNavigation = ctx.hooks.useRef(-1);
|
|
114
133
|
|
|
115
134
|
const compute = () =>
|
|
116
135
|
computeAndApply(
|
|
@@ -121,6 +140,7 @@ defineMacro({
|
|
|
121
140
|
ctx.getValues,
|
|
122
141
|
rawArgs,
|
|
123
142
|
prevOutput,
|
|
143
|
+
lastNavigation,
|
|
124
144
|
localsUpdate,
|
|
125
145
|
);
|
|
126
146
|
|
|
@@ -3,9 +3,10 @@ import { LocalsValuesContext } from '../../markup/render';
|
|
|
3
3
|
import { defineMacro } from '../../define-macro';
|
|
4
4
|
import { MacroError } from './MacroError';
|
|
5
5
|
import { stableKey } from '../../utils/stable-key';
|
|
6
|
-
import { controlEditVersion } from '../../utils/control-edits';
|
|
6
|
+
import { controlEditVersion, editedItem } from '../../utils/control-edits';
|
|
7
7
|
import type { ASTNode } from '../../markup/ast';
|
|
8
8
|
import { checkVariableName } from '../../utils/namespace';
|
|
9
|
+
import { readState } from '../../execute-mutation';
|
|
9
10
|
import { LocalsScope } from './locals-scope';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -131,13 +132,15 @@ defineMacro({
|
|
|
131
132
|
// macros run again for the new item (#45) -- unless the change is a
|
|
132
133
|
// reader editing the item through a control: remounting would drop the
|
|
133
134
|
// control's focus after the first keystroke.
|
|
134
|
-
const
|
|
135
|
+
const since = keys.edits;
|
|
136
|
+
const { variables } = readState();
|
|
135
137
|
keys.edits = controlEditVersion();
|
|
136
138
|
const generations = list.map((item, i) => {
|
|
137
139
|
const contents = stableKey(item);
|
|
138
140
|
if (
|
|
139
141
|
keys.generations[i] === undefined ||
|
|
140
|
-
(contents !== keys.contents[i] &&
|
|
142
|
+
(contents !== keys.contents[i] &&
|
|
143
|
+
!editedItem(since, variables, list, item, i))
|
|
141
144
|
) {
|
|
142
145
|
keys.generations[i] = keys.next++;
|
|
143
146
|
}
|
|
@@ -49,7 +49,10 @@ defineMacro({
|
|
|
49
49
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
50
50
|
const innerRef = useRef<HTMLSpanElement>(null);
|
|
51
51
|
const [clip, setClip] = useState<string | undefined>();
|
|
52
|
-
|
|
52
|
+
// Null until the content has been measured: zero characters is then a
|
|
53
|
+
// result (nothing to animate), not a pending measurement.
|
|
54
|
+
const [measured, setMeasured] = useState<number | null>(null);
|
|
55
|
+
const totalChars = measured ?? 0;
|
|
53
56
|
const [visibleChars, setVisibleChars] = useState(0);
|
|
54
57
|
const visibleCharsRef = useRef(0);
|
|
55
58
|
visibleCharsRef.current = visibleChars;
|
|
@@ -59,7 +62,7 @@ defineMacro({
|
|
|
59
62
|
useEffect(() => {
|
|
60
63
|
if (containerRef.current) {
|
|
61
64
|
const text = containerRef.current.textContent || '';
|
|
62
|
-
|
|
65
|
+
setMeasured(text.length);
|
|
63
66
|
}
|
|
64
67
|
});
|
|
65
68
|
|
|
@@ -81,7 +84,8 @@ defineMacro({
|
|
|
81
84
|
return () => clearInterval(timer);
|
|
82
85
|
}, [totalChars, speed]);
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
// Empty text can still become reactive text later, which restarts typing
|
|
88
|
+
const done = measured !== null && visibleChars >= totalChars;
|
|
85
89
|
|
|
86
90
|
// Clip to the characters revealed so far, as laid out: remeasured when
|
|
87
91
|
// the count changes and when the width the text wraps in does.
|
|
@@ -117,7 +121,7 @@ defineMacro({
|
|
|
117
121
|
class="macro-type-inner"
|
|
118
122
|
style={{
|
|
119
123
|
display: 'inline',
|
|
120
|
-
visibility:
|
|
124
|
+
visibility: measured === null ? 'hidden' : 'visible',
|
|
121
125
|
clipPath: clip,
|
|
122
126
|
}}
|
|
123
127
|
>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from 'preact/hooks';
|
|
1
|
+
import { useContext, useMemo } from 'preact/hooks';
|
|
2
2
|
import { WidgetChildrenContext, renderNodes } from '../../markup/render';
|
|
3
3
|
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
4
4
|
import { evaluate } from '../../expression';
|
|
@@ -9,6 +9,7 @@ import { useRenderOptions } from '../../hooks/use-render-options';
|
|
|
9
9
|
import { LocalsScope } from './locals-scope';
|
|
10
10
|
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
11
11
|
import { wrapContent } from './display';
|
|
12
|
+
import { widgetChildrenOf } from '../../interpolation';
|
|
12
13
|
|
|
13
14
|
export { splitArgs };
|
|
14
15
|
|
|
@@ -44,7 +45,11 @@ function WidgetContent({
|
|
|
44
45
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
|
|
45
46
|
useMergedLocals();
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
+
const outerChildren = useContext(WidgetChildrenContext);
|
|
49
|
+
const childrenValue = useMemo(
|
|
50
|
+
() => widgetChildrenOf(invocationChildren, outerChildren),
|
|
51
|
+
[invocationChildren, outerChildren],
|
|
52
|
+
);
|
|
48
53
|
|
|
49
54
|
// Every widget gets its own local scope, even when it declares no
|
|
50
55
|
// parameters or is invoked without arguments: missing parameters shadow
|
package/src/define-macro.ts
CHANGED
|
@@ -229,7 +229,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
229
229
|
// In program order, also when mutation code performs the input
|
|
230
230
|
ctx.getValue = () => getByPath(readState().variables, segments);
|
|
231
231
|
ctx.setValue = (value: unknown) => {
|
|
232
|
-
noteControlEdit();
|
|
232
|
+
noteControlEdit(segments);
|
|
233
233
|
// The commit mutation code uses, which keeps the references among
|
|
234
234
|
// variables: a write to a draft copies only the path written, and
|
|
235
235
|
// other variables sharing the object would keep the old one.
|
package/src/execute-mutation.ts
CHANGED
|
@@ -138,6 +138,42 @@ export function frozenCopy<T>(value: T): T {
|
|
|
138
138
|
return freeze(cloneValue(value), true);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** Whether `value` and everything it holds is frozen. */
|
|
142
|
+
function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
|
|
143
|
+
if (typeof value !== 'object' || value === null || seen.has(value)) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
// Leaves a freeze cannot lock (typed array elements) and that a copy would
|
|
147
|
+
// detach from what they share (a buffer): handed out as they are
|
|
148
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
|
|
149
|
+
if (!Object.isFrozen(value)) return false;
|
|
150
|
+
seen.add(value);
|
|
151
|
+
// What a Map or Set holds is not in its properties (#388)
|
|
152
|
+
if (value instanceof Map) {
|
|
153
|
+
for (const [key, entry] of value) {
|
|
154
|
+
if (!deeplyFrozen(key, seen) || !deeplyFrozen(entry, seen)) return false;
|
|
155
|
+
}
|
|
156
|
+
} else if (value instanceof Set) {
|
|
157
|
+
for (const member of value) {
|
|
158
|
+
if (!deeplyFrozen(member, seen)) return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return Reflect.ownKeys(value).every((key) => {
|
|
162
|
+
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
163
|
+
return !desc || !('value' in desc) || deeplyFrozen(desc.value, seen);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A value read from the store, made safe to hand out: the store holds plain
|
|
169
|
+
* values frozen, but not registered class instances, whose methods could
|
|
170
|
+
* change the recorded history through the live object. Such a value is
|
|
171
|
+
* returned as a frozen copy, like a read inside mutation code.
|
|
172
|
+
*/
|
|
173
|
+
export function readOnlyValue<T>(value: T): T {
|
|
174
|
+
return deeplyFrozen(value) ? value : frozenCopy(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
141
177
|
/**
|
|
142
178
|
* Apply a write that reached the store while mutations execute to their
|
|
143
179
|
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
@@ -9,14 +9,31 @@ const FOCUSABLE = [
|
|
|
9
9
|
'select:not([disabled])',
|
|
10
10
|
'textarea:not([disabled])',
|
|
11
11
|
'iframe',
|
|
12
|
-
'
|
|
12
|
+
'summary',
|
|
13
|
+
'[contenteditable]',
|
|
13
14
|
'[tabindex]',
|
|
14
15
|
].join(',');
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Whether `el` is in a closed `<details>`, other than in the summary that
|
|
19
|
+
* toggles it: the browser renders none of that content (#389).
|
|
20
|
+
*/
|
|
21
|
+
function inClosedDetails(el: HTMLElement): boolean {
|
|
22
|
+
for (let e = el.parentElement; e; e = e.parentElement) {
|
|
23
|
+
if (e.localName !== 'details' || e.hasAttribute('open')) continue;
|
|
24
|
+
const summary = Array.from(e.children).find(
|
|
25
|
+
(c) => c.localName === 'summary',
|
|
26
|
+
);
|
|
27
|
+
if (!summary?.contains(el)) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
/**
|
|
17
33
|
* Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
|
|
18
|
-
* (inherited, so the computed value covers ancestors)
|
|
19
|
-
* `display: none` element
|
|
34
|
+
* (inherited, so the computed value covers ancestors), not inside a
|
|
35
|
+
* `display: none` element and not in the hidden content of a closed
|
|
36
|
+
* `<details>`.
|
|
20
37
|
*/
|
|
21
38
|
function isRendered(el: HTMLElement): boolean {
|
|
22
39
|
const { visibility } = getComputedStyle(el);
|
|
@@ -24,7 +41,21 @@ function isRendered(el: HTMLElement): boolean {
|
|
|
24
41
|
for (let e: HTMLElement | null = el; e; e = e.parentElement) {
|
|
25
42
|
if (getComputedStyle(e).display === 'none') return false;
|
|
26
43
|
}
|
|
27
|
-
return
|
|
44
|
+
return !inClosedDetails(el);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether `el` can be focused at all: shown, rendered, enabled (also by its
|
|
49
|
+
* fieldset) and not inert.
|
|
50
|
+
*/
|
|
51
|
+
function isAvailable(el: HTMLElement): boolean {
|
|
52
|
+
return (
|
|
53
|
+
!el.hidden &&
|
|
54
|
+
!el.closest('[hidden], [inert]') &&
|
|
55
|
+
// Also covers controls disabled by a <fieldset disabled> ancestor
|
|
56
|
+
!el.matches(':disabled') &&
|
|
57
|
+
isRendered(el)
|
|
58
|
+
);
|
|
28
59
|
}
|
|
29
60
|
|
|
30
61
|
/** The `tabindex` of `el` as an integer, or null when absent or invalid. */
|
|
@@ -35,19 +66,46 @@ function tabIndexAttr(el: HTMLElement): number | null {
|
|
|
35
66
|
}
|
|
36
67
|
|
|
37
68
|
/**
|
|
38
|
-
* Whether the tab key can reach `el
|
|
39
|
-
* scripted focus, and of a
|
|
40
|
-
* member only (the first one
|
|
69
|
+
* Whether the tab key can reach `el`, an available element (see
|
|
70
|
+
* isAvailable): a negative tabindex only allows scripted focus, and of a
|
|
71
|
+
* radio group the browser tabs to the checked member only (the first one
|
|
72
|
+
* when none is checked), of the members it can reach.
|
|
41
73
|
*/
|
|
42
74
|
function isTabStop(el: HTMLElement): boolean {
|
|
43
75
|
const tabIndex = tabIndexAttr(el);
|
|
44
76
|
if (tabIndex !== null && tabIndex < 0) return false;
|
|
77
|
+
// Only an editing host takes focus, not `contenteditable="false"` or
|
|
78
|
+
// content nested in a host
|
|
79
|
+
if (
|
|
80
|
+
el.hasAttribute('contenteditable') &&
|
|
81
|
+
!el.matches('a[href], button, input, select, textarea, iframe, summary') &&
|
|
82
|
+
(!el.isContentEditable || el.parentElement?.isContentEditable)
|
|
83
|
+
) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
// Only a details element's first summary child is its toggle
|
|
87
|
+
if (el.localName === 'summary') {
|
|
88
|
+
const details = el.parentElement;
|
|
89
|
+
if (
|
|
90
|
+
details?.localName !== 'details' ||
|
|
91
|
+
Array.from(details.children).find((c) => c.localName === 'summary') !== el
|
|
92
|
+
) {
|
|
93
|
+
return tabIndex !== null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
45
96
|
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
|
46
97
|
const group = Array.from(
|
|
47
98
|
(
|
|
48
99
|
el.form ?? (el.getRootNode() as ParentNode)
|
|
49
100
|
).querySelectorAll<HTMLInputElement>('input[type="radio"]'),
|
|
50
|
-
).filter(
|
|
101
|
+
).filter(
|
|
102
|
+
(r) =>
|
|
103
|
+
r.name === el.name &&
|
|
104
|
+
r.form === el.form &&
|
|
105
|
+
// Of the members Tab can reach (#390)
|
|
106
|
+
(tabIndexAttr(r) ?? 0) >= 0 &&
|
|
107
|
+
isAvailable(r),
|
|
108
|
+
);
|
|
51
109
|
const stop = group.find((r) => r.checked) ?? group[0];
|
|
52
110
|
return el === stop;
|
|
53
111
|
}
|
|
@@ -61,15 +119,7 @@ function isTabStop(el: HTMLElement): boolean {
|
|
|
61
119
|
function focusables(root: HTMLElement): HTMLElement[] {
|
|
62
120
|
const stops = Array.from(
|
|
63
121
|
root.querySelectorAll<HTMLElement>(FOCUSABLE),
|
|
64
|
-
).filter(
|
|
65
|
-
(el) =>
|
|
66
|
-
!el.hidden &&
|
|
67
|
-
!el.closest('[hidden], [inert]') &&
|
|
68
|
-
// Also covers controls disabled by a <fieldset disabled> ancestor
|
|
69
|
-
!el.matches(':disabled') &&
|
|
70
|
-
isRendered(el) &&
|
|
71
|
-
isTabStop(el),
|
|
72
|
-
);
|
|
122
|
+
).filter((el) => isAvailable(el) && isTabStop(el));
|
|
73
123
|
const positive = (el: HTMLElement) => Math.max(tabIndexAttr(el) ?? 0, 0);
|
|
74
124
|
// Array.prototype.sort is stable: equal tabindexes keep document order
|
|
75
125
|
return [
|