@rohal12/spindle 0.59.12 → 0.59.13
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 +96 -20
- package/dist/pkg/story-variables.js +2 -1
- package/package.json +1 -1
- 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/define-macro.ts +1 -1
- package/src/execute-mutation.ts +26 -0
- package/src/hooks/use-modal-focus.ts +21 -1
- package/src/markup/render.tsx +28 -3
- package/src/story-api.ts +2 -1
- package/src/story-variables.ts +12 -2
- package/src/utils/control-edits.ts +36 -3
- package/types/tooling.d.ts +7 -1
package/dist/pkg/headless.js
CHANGED
|
@@ -11791,7 +11791,7 @@ var InlineContext = createContext(false);
|
|
|
11791
11791
|
/**
|
|
11792
11792
|
* True while rendering inside an element whose content is not markdown: SVG
|
|
11793
11793
|
* (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
|
|
11794
|
-
* and `<
|
|
11794
|
+
* `<textarea>` and `<style>`, whose text (indentation, `#`, `*`, ...) is literal.
|
|
11795
11795
|
* Macro and widget bodies read it so their content stays literal too.
|
|
11796
11796
|
*/
|
|
11797
11797
|
var RawTextContext = createContext(false);
|
|
@@ -11802,7 +11802,11 @@ var RawTextContext = createContext(false);
|
|
|
11802
11802
|
var SvgContext = createContext(false);
|
|
11803
11803
|
var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
11804
11804
|
/** Elements whose content is literal text, not markdown. */
|
|
11805
|
-
var PREFORMATTED_ELEMENTS = new Set([
|
|
11805
|
+
var PREFORMATTED_ELEMENTS = new Set([
|
|
11806
|
+
"pre",
|
|
11807
|
+
"textarea",
|
|
11808
|
+
"style"
|
|
11809
|
+
]);
|
|
11806
11810
|
var WidgetChildrenContext = createContext(null);
|
|
11807
11811
|
function placeholderHtml(nonce, index) {
|
|
11808
11812
|
return `<span data-tw=${nonce}:${index}></span>`;
|
|
@@ -12021,6 +12025,7 @@ var INLINE_ELEMENTS = new Set([
|
|
|
12021
12025
|
"span",
|
|
12022
12026
|
"strong",
|
|
12023
12027
|
"sub",
|
|
12028
|
+
"summary",
|
|
12024
12029
|
"sup",
|
|
12025
12030
|
"time",
|
|
12026
12031
|
"u",
|
|
@@ -12225,6 +12230,12 @@ function resolveAttributeValue(name, value, scope, errors) {
|
|
|
12225
12230
|
for (const error of result.errors) errors.push([name, error]);
|
|
12226
12231
|
return result.text;
|
|
12227
12232
|
}
|
|
12233
|
+
/** The index of the summary element opening `nodes`, or -1 when none does. */
|
|
12234
|
+
function leadingSummaryIndex(nodes) {
|
|
12235
|
+
const at = nodes.findIndex((n) => n.type !== "text" || n.value.trim() !== "");
|
|
12236
|
+
const first = nodes[at];
|
|
12237
|
+
return first?.type === "html" && first.tag.toLowerCase() === "summary" ? at : -1;
|
|
12238
|
+
}
|
|
12228
12239
|
function HtmlNodeRenderer({ node }) {
|
|
12229
12240
|
const scope = useTextScope();
|
|
12230
12241
|
const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
|
|
@@ -12244,11 +12255,17 @@ function HtmlNodeRenderer({ node }) {
|
|
|
12244
12255
|
let children = void 0;
|
|
12245
12256
|
if (node.children.length > 0) if (inRaw || isRawRoot) children = renderInlineNodes(node.children);
|
|
12246
12257
|
else {
|
|
12247
|
-
|
|
12258
|
+
const summaryAt = tag === "details" ? leadingSummaryIndex(node.children) : -1;
|
|
12259
|
+
const body = (nodes) => renderNodes(nodes, {
|
|
12248
12260
|
nobr,
|
|
12249
12261
|
locals,
|
|
12250
12262
|
inline: isInline
|
|
12251
12263
|
});
|
|
12264
|
+
children = summaryAt === -1 ? body(node.children) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
12265
|
+
body(node.children.slice(0, summaryAt)),
|
|
12266
|
+
renderSingleNode(node.children[summaryAt]),
|
|
12267
|
+
body(node.children.slice(summaryAt + 1))
|
|
12268
|
+
] });
|
|
12252
12269
|
if (isInline !== parentInline) children = /* @__PURE__ */ jsx(InlineContext.Provider, {
|
|
12253
12270
|
value: isInline,
|
|
12254
12271
|
children
|
|
@@ -15582,6 +15599,26 @@ var clonersFor = (paths) => {
|
|
|
15582
15599
|
function frozenCopy(value) {
|
|
15583
15600
|
return freeze(cloneValue(value), true);
|
|
15584
15601
|
}
|
|
15602
|
+
/** Whether `value` and everything it holds is frozen. */
|
|
15603
|
+
function deeplyFrozen(value, seen = /* @__PURE__ */ new Set()) {
|
|
15604
|
+
if (typeof value !== "object" || value === null || seen.has(value)) return true;
|
|
15605
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
|
|
15606
|
+
if (!Object.isFrozen(value)) return false;
|
|
15607
|
+
seen.add(value);
|
|
15608
|
+
return Reflect.ownKeys(value).every((key) => {
|
|
15609
|
+
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
15610
|
+
return !desc || !("value" in desc) || deeplyFrozen(desc.value, seen);
|
|
15611
|
+
});
|
|
15612
|
+
}
|
|
15613
|
+
/**
|
|
15614
|
+
* A value read from the store, made safe to hand out: the store holds plain
|
|
15615
|
+
* values frozen, but not registered class instances, whose methods could
|
|
15616
|
+
* change the recorded history through the live object. Such a value is
|
|
15617
|
+
* returned as a frozen copy, like a read inside mutation code.
|
|
15618
|
+
*/
|
|
15619
|
+
function readOnlyValue(value) {
|
|
15620
|
+
return deeplyFrozen(value) ? value : frozenCopy(value);
|
|
15621
|
+
}
|
|
15585
15622
|
/**
|
|
15586
15623
|
* Apply a write that reached the store while mutations execute to their
|
|
15587
15624
|
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
@@ -17265,7 +17302,8 @@ var FOCUSABLE = [
|
|
|
17265
17302
|
"select:not([disabled])",
|
|
17266
17303
|
"textarea:not([disabled])",
|
|
17267
17304
|
"iframe",
|
|
17268
|
-
"
|
|
17305
|
+
"summary",
|
|
17306
|
+
"[contenteditable]",
|
|
17269
17307
|
"[tabindex]"
|
|
17270
17308
|
].join(",");
|
|
17271
17309
|
/**
|
|
@@ -17293,6 +17331,11 @@ function tabIndexAttr(el) {
|
|
|
17293
17331
|
function isTabStop(el) {
|
|
17294
17332
|
const tabIndex = tabIndexAttr(el);
|
|
17295
17333
|
if (tabIndex !== null && tabIndex < 0) return false;
|
|
17334
|
+
if (el.hasAttribute("contenteditable") && !el.matches("a[href], button, input, select, textarea, iframe, summary") && (!el.isContentEditable || el.parentElement?.isContentEditable)) return false;
|
|
17335
|
+
if (el.localName === "summary") {
|
|
17336
|
+
const details = el.parentElement;
|
|
17337
|
+
if (details?.localName !== "details" || Array.from(details.children).find((c) => c.localName === "summary") !== el) return tabIndex !== null;
|
|
17338
|
+
}
|
|
17296
17339
|
if (el instanceof HTMLInputElement && el.type === "radio" && el.name) {
|
|
17297
17340
|
const group = Array.from((el.form ?? el.getRootNode()).querySelectorAll("input[type=\"radio\"]")).filter((r) => r.name === el.name && r.form === el.form && !r.disabled);
|
|
17298
17341
|
return el === (group.find((r) => r.checked) ?? group[0]);
|
|
@@ -17816,18 +17859,38 @@ var settings = {
|
|
|
17816
17859
|
//#endregion
|
|
17817
17860
|
//#region src/utils/control-edits.ts
|
|
17818
17861
|
/**
|
|
17819
|
-
*
|
|
17862
|
+
* Records the edits a reader made through a control bound to a story
|
|
17820
17863
|
* variable (`{textbox}`, `{checkbox}`, ...). {for} tells these apart from a
|
|
17821
17864
|
* list that is replaced: typing into a control inside an iteration changes
|
|
17822
17865
|
* its item, but must not remount the iteration and drop the control's focus.
|
|
17866
|
+
* An edit is attributed to the variable path it wrote, so a replacement made
|
|
17867
|
+
* elsewhere (a watcher reacting to the edit) still remounts.
|
|
17823
17868
|
*/
|
|
17824
17869
|
var version = 0;
|
|
17825
|
-
|
|
17826
|
-
|
|
17870
|
+
var MAX_LOG = 256;
|
|
17871
|
+
var log = [];
|
|
17872
|
+
function noteControlEdit(segments) {
|
|
17873
|
+
log.push({
|
|
17874
|
+
version: ++version,
|
|
17875
|
+
segments
|
|
17876
|
+
});
|
|
17877
|
+
if (log.length > MAX_LOG) log.shift();
|
|
17827
17878
|
}
|
|
17828
17879
|
function controlEditVersion() {
|
|
17829
17880
|
return version;
|
|
17830
17881
|
}
|
|
17882
|
+
/**
|
|
17883
|
+
* Whether an edit since `since` wrote inside `item`, element `index` of
|
|
17884
|
+
* `list`: the path leads through the item (compared by reference in
|
|
17885
|
+
* `variables`), or, for a primitive item, ends at its slot in the list.
|
|
17886
|
+
*/
|
|
17887
|
+
function editedItem(since, variables, list, item, index) {
|
|
17888
|
+
return log.some(({ version: v, segments }) => {
|
|
17889
|
+
if (v <= since) return false;
|
|
17890
|
+
if (typeof item === "object" && item !== null) return segments.some((_, n) => getByPath(variables, segments.slice(0, n + 1)) === item);
|
|
17891
|
+
return segments[segments.length - 1] === String(index) && getByPath(variables, segments.slice(0, -1)) === list;
|
|
17892
|
+
});
|
|
17893
|
+
}
|
|
17831
17894
|
//#endregion
|
|
17832
17895
|
//#region src/action-registry.ts
|
|
17833
17896
|
/** The fields of a StoryAction that are left out when unset. */
|
|
@@ -18073,7 +18136,7 @@ function defineMacro(config, source = "builtin") {
|
|
|
18073
18136
|
ctx.value = useStoryStore((s) => getByPath(s.variables, segments));
|
|
18074
18137
|
ctx.getValue = () => getByPath(readState().variables, segments);
|
|
18075
18138
|
ctx.setValue = (value) => {
|
|
18076
|
-
noteControlEdit();
|
|
18139
|
+
noteControlEdit(segments);
|
|
18077
18140
|
mutateState((work, adopt) => {
|
|
18078
18141
|
setByPath(work.variables, segments, adopt(value), { createMissing: true });
|
|
18079
18142
|
});
|
|
@@ -18208,7 +18271,7 @@ function createStoryAPI() {
|
|
|
18208
18271
|
const source = scope ?? useStoryStore.getState();
|
|
18209
18272
|
const namespace = isTransient ? source.transient : source.variables;
|
|
18210
18273
|
const value = key.includes(".") ? getByPath(namespace, key.split(".")) : ownValue(namespace, key);
|
|
18211
|
-
return scope ? frozenCopy(value) : value;
|
|
18274
|
+
return scope ? frozenCopy(value) : readOnlyValue(value);
|
|
18212
18275
|
},
|
|
18213
18276
|
set(nameOrVars, value) {
|
|
18214
18277
|
const entries = typeof nameOrVars === "string" ? [[nameOrVars, value]] : Object.entries(nameOrVars);
|
|
@@ -18920,7 +18983,8 @@ var PRIMITIVE_SAMPLES = {
|
|
|
18920
18983
|
};
|
|
18921
18984
|
function inferSchema(value) {
|
|
18922
18985
|
if (Array.isArray(value)) return { type: "array" };
|
|
18923
|
-
if (value
|
|
18986
|
+
if (value === null) return { type: "null" };
|
|
18987
|
+
if (typeof value === "object") {
|
|
18924
18988
|
const fields = /* @__PURE__ */ new Map();
|
|
18925
18989
|
for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
|
|
18926
18990
|
return {
|
|
@@ -18983,6 +19047,7 @@ function validateRef(ref, schema) {
|
|
|
18983
19047
|
for (let i = 1; i < parts.length; i++) {
|
|
18984
19048
|
const part = parts[i];
|
|
18985
19049
|
if (current.type === "array") return null;
|
|
19050
|
+
if (current.type === "null") return null;
|
|
18986
19051
|
const sample = PRIMITIVE_SAMPLES[current.type];
|
|
18987
19052
|
if (sample && part in sample) {
|
|
18988
19053
|
const memberType = typeof sample[part];
|
|
@@ -19211,23 +19276,31 @@ function assignmentArgs(rawArgs, args) {
|
|
|
19211
19276
|
/**
|
|
19212
19277
|
* Evaluate `expr` against the current values — the store's state and the
|
|
19213
19278
|
* enclosing locals scope's live values (`getLocals`) — and write the result
|
|
19214
|
-
* to the target if it differs from the previous output (always, the first
|
|
19279
|
+
* to the target if it differs from the previous output or the target's value (always, the first
|
|
19215
19280
|
* time). Reading live values rather than the render's snapshot means the
|
|
19216
19281
|
* first computation sees a local a preceding {set} just assigned (the scope's
|
|
19217
19282
|
* context value only catches up on its re-render), and the first computation
|
|
19218
19283
|
* and later recomputations read the same source.
|
|
19219
19284
|
*/
|
|
19220
|
-
function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRef, localsUpdate) {
|
|
19285
|
+
function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRef, lastNavigation, localsUpdate) {
|
|
19221
19286
|
const location = currentSourceLocation();
|
|
19222
19287
|
let newValue;
|
|
19288
|
+
let current = prevRef.current;
|
|
19289
|
+
let navigated = false;
|
|
19290
|
+
const { navigationId } = useStoryStore.getState();
|
|
19223
19291
|
try {
|
|
19224
19292
|
const { variables, temporary, transient } = readState();
|
|
19293
|
+
if (!isLocal) {
|
|
19294
|
+
current = ownValue(isTemp ? temporary : variables, name);
|
|
19295
|
+
navigated = navigationId !== lastNavigation.current;
|
|
19296
|
+
}
|
|
19225
19297
|
newValue = evaluate(expr, variables, temporary, getLocals(), transient);
|
|
19226
19298
|
} catch (err) {
|
|
19227
19299
|
logMacroError(`computed ${rawArgs}`, err, location);
|
|
19228
19300
|
return;
|
|
19229
19301
|
}
|
|
19230
|
-
|
|
19302
|
+
lastNavigation.current = navigationId;
|
|
19303
|
+
if (!deepEqualStrict(prevRef.current, newValue) || navigated && !deepEqualStrict(current, newValue)) {
|
|
19231
19304
|
prevRef.current = newValue;
|
|
19232
19305
|
if (isLocal) try {
|
|
19233
19306
|
localsUpdate(name, newValue);
|
|
@@ -19276,7 +19349,8 @@ defineMacro({
|
|
|
19276
19349
|
const name = target.slice(1);
|
|
19277
19350
|
const localsUpdate = isLocal ? ctx.update : null;
|
|
19278
19351
|
const prevOutput = ctx.hooks.useRef(UNSET);
|
|
19279
|
-
const
|
|
19352
|
+
const lastNavigation = ctx.hooks.useRef(-1);
|
|
19353
|
+
const compute = () => computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, lastNavigation, localsUpdate);
|
|
19280
19354
|
const ran = ctx.hooks.useRef(false);
|
|
19281
19355
|
if (!ran.current) {
|
|
19282
19356
|
ran.current = true;
|
|
@@ -19539,11 +19613,12 @@ defineMacro({
|
|
|
19539
19613
|
});
|
|
19540
19614
|
}
|
|
19541
19615
|
const { itemVar, indexVar } = loop;
|
|
19542
|
-
const
|
|
19616
|
+
const since = keys.edits;
|
|
19617
|
+
const { variables } = readState();
|
|
19543
19618
|
keys.edits = controlEditVersion();
|
|
19544
19619
|
const generations = list.map((item, i) => {
|
|
19545
19620
|
const contents = stableKey(item);
|
|
19546
|
-
if (keys.generations[i] === void 0 || contents !== keys.contents[i] && !
|
|
19621
|
+
if (keys.generations[i] === void 0 || contents !== keys.contents[i] && !editedItem(since, variables, list, item, i)) keys.generations[i] = keys.next++;
|
|
19547
19622
|
keys.contents[i] = contents;
|
|
19548
19623
|
return keys.generations[i];
|
|
19549
19624
|
});
|
|
@@ -19937,12 +20012,13 @@ defineMacro({
|
|
|
19937
20012
|
const containerRef = useRef(null);
|
|
19938
20013
|
const innerRef = useRef(null);
|
|
19939
20014
|
const [clip, setClip] = useState();
|
|
19940
|
-
const [
|
|
20015
|
+
const [measured, setMeasured] = useState(null);
|
|
20016
|
+
const totalChars = measured ?? 0;
|
|
19941
20017
|
const [visibleChars, setVisibleChars] = useState(0);
|
|
19942
20018
|
const visibleCharsRef = useRef(0);
|
|
19943
20019
|
visibleCharsRef.current = visibleChars;
|
|
19944
20020
|
useEffect(() => {
|
|
19945
|
-
if (containerRef.current)
|
|
20021
|
+
if (containerRef.current) setMeasured((containerRef.current.textContent || "").length);
|
|
19946
20022
|
});
|
|
19947
20023
|
useEffect(() => {
|
|
19948
20024
|
if (totalChars === 0) return;
|
|
@@ -19958,7 +20034,7 @@ defineMacro({
|
|
|
19958
20034
|
}, speed);
|
|
19959
20035
|
return () => clearInterval(timer);
|
|
19960
20036
|
}, [totalChars, speed]);
|
|
19961
|
-
const done =
|
|
20037
|
+
const done = measured !== null && visibleChars >= totalChars;
|
|
19962
20038
|
useLayoutEffect(() => {
|
|
19963
20039
|
const root = containerRef.current;
|
|
19964
20040
|
const inner = innerRef.current;
|
|
@@ -19989,7 +20065,7 @@ defineMacro({
|
|
|
19989
20065
|
class: "macro-type-inner",
|
|
19990
20066
|
style: {
|
|
19991
20067
|
display: "inline",
|
|
19992
|
-
visibility:
|
|
20068
|
+
visibility: measured === null ? "hidden" : "visible",
|
|
19993
20069
|
clipPath: clip
|
|
19994
20070
|
},
|
|
19995
20071
|
children: ctx.renderInlineNodes(children)
|
|
@@ -11082,7 +11082,8 @@ var VALID_VAR_TYPES = new Set([
|
|
|
11082
11082
|
]);
|
|
11083
11083
|
function inferSchema(value) {
|
|
11084
11084
|
if (Array.isArray(value)) return { type: "array" };
|
|
11085
|
-
if (value
|
|
11085
|
+
if (value === null) return { type: "null" };
|
|
11086
|
+
if (typeof value === "object") {
|
|
11086
11087
|
const fields = /* @__PURE__ */ new Map();
|
|
11087
11088
|
for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
|
|
11088
11089
|
return {
|
package/package.json
CHANGED
|
@@ -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
|
>
|
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,32 @@ 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
|
+
return Reflect.ownKeys(value).every((key) => {
|
|
152
|
+
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
153
|
+
return !desc || !('value' in desc) || deeplyFrozen(desc.value, seen);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A value read from the store, made safe to hand out: the store holds plain
|
|
159
|
+
* values frozen, but not registered class instances, whose methods could
|
|
160
|
+
* change the recorded history through the live object. Such a value is
|
|
161
|
+
* returned as a frozen copy, like a read inside mutation code.
|
|
162
|
+
*/
|
|
163
|
+
export function readOnlyValue<T>(value: T): T {
|
|
164
|
+
return deeplyFrozen(value) ? value : frozenCopy(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
141
167
|
/**
|
|
142
168
|
* Apply a write that reached the store while mutations execute to their
|
|
143
169
|
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
@@ -9,7 +9,8 @@ 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
|
|
|
@@ -42,6 +43,25 @@ function tabIndexAttr(el: HTMLElement): number | null {
|
|
|
42
43
|
function isTabStop(el: HTMLElement): boolean {
|
|
43
44
|
const tabIndex = tabIndexAttr(el);
|
|
44
45
|
if (tabIndex !== null && tabIndex < 0) return false;
|
|
46
|
+
// Only an editing host takes focus, not `contenteditable="false"` or
|
|
47
|
+
// content nested in a host
|
|
48
|
+
if (
|
|
49
|
+
el.hasAttribute('contenteditable') &&
|
|
50
|
+
!el.matches('a[href], button, input, select, textarea, iframe, summary') &&
|
|
51
|
+
(!el.isContentEditable || el.parentElement?.isContentEditable)
|
|
52
|
+
) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
// Only a details element's first summary child is its toggle
|
|
56
|
+
if (el.localName === 'summary') {
|
|
57
|
+
const details = el.parentElement;
|
|
58
|
+
if (
|
|
59
|
+
details?.localName !== 'details' ||
|
|
60
|
+
Array.from(details.children).find((c) => c.localName === 'summary') !== el
|
|
61
|
+
) {
|
|
62
|
+
return tabIndex !== null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
45
65
|
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
|
46
66
|
const group = Array.from(
|
|
47
67
|
(
|
package/src/markup/render.tsx
CHANGED
|
@@ -52,7 +52,7 @@ export const InlineContext = createContext(false);
|
|
|
52
52
|
/**
|
|
53
53
|
* True while rendering inside an element whose content is not markdown: SVG
|
|
54
54
|
* (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
|
|
55
|
-
* and `<
|
|
55
|
+
* `<textarea>` and `<style>`, whose text (indentation, `#`, `*`, ...) is literal.
|
|
56
56
|
* Macro and widget bodies read it so their content stays literal too.
|
|
57
57
|
*/
|
|
58
58
|
export const RawTextContext = createContext(false);
|
|
@@ -66,7 +66,7 @@ const SvgContext = createContext(false);
|
|
|
66
66
|
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
67
67
|
|
|
68
68
|
/** Elements whose content is literal text, not markdown. */
|
|
69
|
-
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
|
|
69
|
+
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
|
|
70
70
|
export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -392,6 +392,7 @@ const INLINE_ELEMENTS = new Set([
|
|
|
392
392
|
'span',
|
|
393
393
|
'strong',
|
|
394
394
|
'sub',
|
|
395
|
+
'summary',
|
|
395
396
|
'sup',
|
|
396
397
|
'time',
|
|
397
398
|
'u',
|
|
@@ -632,6 +633,15 @@ function resolveAttributeValue(
|
|
|
632
633
|
return result.text;
|
|
633
634
|
}
|
|
634
635
|
|
|
636
|
+
/** The index of the summary element opening `nodes`, or -1 when none does. */
|
|
637
|
+
function leadingSummaryIndex(nodes: ASTNode[]): number {
|
|
638
|
+
const at = nodes.findIndex((n) => n.type !== 'text' || n.value.trim() !== '');
|
|
639
|
+
const first = nodes[at];
|
|
640
|
+
return first?.type === 'html' && first.tag.toLowerCase() === 'summary'
|
|
641
|
+
? at
|
|
642
|
+
: -1;
|
|
643
|
+
}
|
|
644
|
+
|
|
635
645
|
function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
636
646
|
const scope = useTextScope();
|
|
637
647
|
const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
|
|
@@ -658,7 +668,22 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
658
668
|
if (inRaw || isRawRoot) {
|
|
659
669
|
children = renderInlineNodes(node.children);
|
|
660
670
|
} else {
|
|
661
|
-
|
|
671
|
+
// A details element's summary stays its direct child: it is rendered
|
|
672
|
+
// apart from the markdown of the rest of the content.
|
|
673
|
+
const summaryAt =
|
|
674
|
+
tag === 'details' ? leadingSummaryIndex(node.children) : -1;
|
|
675
|
+
const body = (nodes: ASTNode[]) =>
|
|
676
|
+
renderNodes(nodes, { nobr, locals, inline: isInline });
|
|
677
|
+
children =
|
|
678
|
+
summaryAt === -1 ? (
|
|
679
|
+
body(node.children)
|
|
680
|
+
) : (
|
|
681
|
+
<>
|
|
682
|
+
{body(node.children.slice(0, summaryAt))}
|
|
683
|
+
{renderSingleNode(node.children[summaryAt]!)}
|
|
684
|
+
{body(node.children.slice(summaryAt + 1))}
|
|
685
|
+
</>
|
|
686
|
+
);
|
|
662
687
|
if (isInline !== parentInline) {
|
|
663
688
|
children = (
|
|
664
689
|
<InlineContext.Provider value={isInline}>
|
package/src/story-api.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { getBackendType } from './saves/storage';
|
|
|
23
23
|
import { registerClass } from './class-registry';
|
|
24
24
|
import {
|
|
25
25
|
frozenCopy,
|
|
26
|
+
readOnlyValue,
|
|
26
27
|
getActiveMutationScope,
|
|
27
28
|
mutateState,
|
|
28
29
|
} from './execute-mutation';
|
|
@@ -286,7 +287,7 @@ function createStoryAPI(): StoryAPI {
|
|
|
286
287
|
const value = key.includes('.')
|
|
287
288
|
? getByPath(namespace, key.split('.'))
|
|
288
289
|
: ownValue(namespace, key);
|
|
289
|
-
return scope ? frozenCopy(value) : value;
|
|
290
|
+
return scope ? frozenCopy(value) : readOnlyValue(value);
|
|
290
291
|
},
|
|
291
292
|
|
|
292
293
|
set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
|
package/src/story-variables.ts
CHANGED
|
@@ -32,7 +32,13 @@ function tokensOf(text: string, textMode: boolean): Token[] {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export type VarType =
|
|
35
|
+
export type VarType =
|
|
36
|
+
| 'number'
|
|
37
|
+
| 'string'
|
|
38
|
+
| 'boolean'
|
|
39
|
+
| 'array'
|
|
40
|
+
| 'object'
|
|
41
|
+
| 'null';
|
|
36
42
|
|
|
37
43
|
export interface FieldSchema {
|
|
38
44
|
type: VarType;
|
|
@@ -66,7 +72,9 @@ function inferSchema(value: unknown): FieldSchema {
|
|
|
66
72
|
if (Array.isArray(value)) {
|
|
67
73
|
return { type: 'array' };
|
|
68
74
|
}
|
|
69
|
-
|
|
75
|
+
// A default of null means "nothing yet"; the value may later be anything
|
|
76
|
+
if (value === null) return { type: 'null' };
|
|
77
|
+
if (typeof value === 'object') {
|
|
70
78
|
const fields = new Map<string, FieldSchema>();
|
|
71
79
|
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
72
80
|
fields.set(key, inferSchema(val));
|
|
@@ -156,6 +164,8 @@ function validateRef(
|
|
|
156
164
|
// Arrays have built-in methods/properties (push, find, length, etc.)
|
|
157
165
|
// so any field access on an array is allowed.
|
|
158
166
|
if (current.type === 'array') return null;
|
|
167
|
+
// A null default may later hold a value of any shape
|
|
168
|
+
if (current.type === 'null') return null;
|
|
159
169
|
|
|
160
170
|
// Primitives expose their wrapper's built-ins (length, toUpperCase,
|
|
161
171
|
// toFixed, etc.). Keep validating past properties of primitive type.
|