@heyramzi/ui 3.7.0 → 3.7.1

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.
@@ -1,6 +1,6 @@
1
1
  // A pen node's accessible name. Svelte Flow puts `ariaLabel` on the focusable node wrapper,
2
2
  // so the name has to live on the node object and follow its words as they're edited.
3
- import { useSvelteFlow } from "@xyflow/svelte";
3
+ import { useStore } from "@xyflow/svelte";
4
4
  import { untrack } from "svelte";
5
5
  const cap = (word) => word.charAt(0).toUpperCase() + word.slice(1);
6
6
  /** "Idea" for a text, "Rectangle: Idea" for a labelled shape, "Pen stroke" for a drawing. */
@@ -11,15 +11,38 @@ export function penNodeName(kind, data) {
11
11
  const label = data.label?.trim();
12
12
  return label ? `${shape}: ${label}` : shape;
13
13
  }
14
+ // One write per canvas per tick: a write per node redrew the board 473 times opening 1,000 cards.
15
+ const pending = new WeakMap();
16
+ function queueName(store, nodeId, label) {
17
+ let names = pending.get(store);
18
+ if (!names) {
19
+ const queued = new Map();
20
+ names = queued;
21
+ pending.set(store, queued);
22
+ queueMicrotask(() => {
23
+ pending.delete(store);
24
+ let changed = false;
25
+ const nodes = store.nodes.map((node) => {
26
+ const name = queued.get(node.id);
27
+ if (name === undefined || node.ariaLabel === name) return node;
28
+ changed = true;
29
+ return {
30
+ ...node,
31
+ ariaLabel: name
32
+ };
33
+ });
34
+ if (changed) store.nodes = nodes;
35
+ });
36
+ }
37
+ names.set(nodeId, label);
38
+ }
14
39
  /** Keeps node `id`'s ariaLabel equal to `name()`. Call it from a node component. */
15
40
  export function useNodeName(id, name) {
16
- const { getNode, updateNode } = useSvelteFlow();
41
+ const store = useStore();
17
42
  $effect(() => {
18
43
  const nodeId = id();
19
44
  const label = name();
20
- // untrack: updateNode reads the node list, and tracking it would rerun this on every write.
21
- untrack(() => {
22
- if (getNode(nodeId)?.ariaLabel !== label) updateNode(nodeId, { ariaLabel: label });
23
- });
45
+ // untrack: the write reads the node list, and tracking it would rerun this on every write.
46
+ untrack(() => queueName(store, nodeId, label));
24
47
  });
25
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
5
5
  "homepage": "https://ui.heyramzi.com",
6
6
  "license": "MIT",
@@ -143,6 +143,12 @@
143
143
  "./animations/*": {
144
144
  "default": "./src/animations/*"
145
145
  },
146
+ "./shadcn/hooks/*.svelte": {
147
+ "svelte": "./src/shadcn/hooks/*.svelte.js",
148
+ "types": "./src/shadcn/hooks/*.svelte.ts",
149
+ "node": "./dist/shadcn/hooks/*.svelte.js",
150
+ "default": "./src/shadcn/hooks/*.svelte.js"
151
+ },
146
152
  "./shadcn/hooks/*": {
147
153
  "svelte": "./src/shadcn/hooks/*.ts",
148
154
  "types": "./src/shadcn/hooks/*.ts",
@@ -308,6 +314,12 @@
308
314
  "default": "./src/canvas/*.svelte"
309
315
  },
310
316
  "./canvas/*.css": "./src/canvas/*.css",
317
+ "./canvas/*.svelte.js": {
318
+ "types": "./src/canvas/*.svelte.ts",
319
+ "svelte": "./src/canvas/*.svelte.js",
320
+ "node": "./dist/canvas/*.svelte.js",
321
+ "default": "./src/canvas/*.svelte.js"
322
+ },
311
323
  "./canvas/*.js": {
312
324
  "types": "./src/canvas/*.ts",
313
325
  "svelte": "./src/canvas/*.ts",
@@ -0,0 +1,4 @@
1
+ /** Deletes every `x.svelte.js` that sits beside an `x.svelte.ts` under `src`. */
2
+ export function cleanSvelteModules(src: any): void;
3
+ /** Writes a type-free `x.svelte.js` beside each `x.svelte.ts` under `src`, and returns how many. */
4
+ export function writeSvelteModules(src: any): Promise<number>;
@@ -0,0 +1,38 @@
1
+ // vite-plugin-svelte prebundles a dependency's `.svelte.ts` with its types still in, so a consumer's
2
+ // dev server dies on the first `import type` (3.6.0, the chat windows' reveal.svelte.ts).
3
+
4
+ // The tarball carries a type-free `x.svelte.js` twin. Importers already write `.svelte.js`, so Vite
5
+ // takes the twin and TypeScript keeps the `.ts`; the repo, with no twin, falls back to it.
6
+ import { readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { transformWithOxc } from "vite-plus";
9
+
10
+ const MODULE = /\.svelte\.ts$/;
11
+ const TEST = /\.(test|spec)\.svelte\.ts$/;
12
+
13
+ function walk(dir, out = []) {
14
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
15
+ const p = join(dir, e.name);
16
+ if (e.isDirectory()) walk(p, out);
17
+ else if (MODULE.test(e.name) && !TEST.test(e.name)) out.push(p);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ /** Deletes every `x.svelte.js` that sits beside an `x.svelte.ts` under `src`. */
23
+ export function cleanSvelteModules(src) {
24
+ for (const file of walk(src)) rmSync(file.replace(MODULE, ".svelte.js"), { force: true });
25
+ }
26
+
27
+ /** Writes a type-free `x.svelte.js` beside each `x.svelte.ts` under `src`, and returns how many. */
28
+ export async function writeSvelteModules(src) {
29
+ cleanSvelteModules(src);
30
+ const files = walk(src);
31
+ await Promise.all(
32
+ files.map(async (file) => {
33
+ const { code } = await transformWithOxc(readFileSync(file, "utf8"), file, { lang: "ts" });
34
+ writeFileSync(file.replace(MODULE, ".svelte.js"), code);
35
+ }),
36
+ );
37
+ return files.length;
38
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // Writes `x.svelte.js` beside each `x.svelte.ts` for the tarball; lib/svelte-modules.mjs says why.
3
+ // prepack writes them, postpack deletes them (`--clean`): left in src, they'd shadow the source.
4
+ import { dirname, join } from "node:path";
5
+ import { argv } from "node:process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { cleanSvelteModules, writeSvelteModules } from "./lib/svelte-modules.mjs";
8
+
9
+ const src = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
10
+ if (argv.includes("--clean")) cleanSvelteModules(src);
11
+ else
12
+ console.log(
13
+ `svelte-modules: ${await writeSvelteModules(src)} module(s) beside src/**/*.svelte.ts`,
14
+ );
@@ -0,0 +1,106 @@
1
+ import { untrack } from "svelte";
2
+ /** Milliseconds each beat holds, read as one conversation's pacing, not a transition. */
3
+ export const REVEAL_BEATS = {
4
+ lead: 400,
5
+ user: 900,
6
+ thinking: 900,
7
+ stream: 1500,
8
+ running: 1300,
9
+ after: 350
10
+ };
11
+ /** The timeline for a conversation: when each message enters and settles, and when it ends. */
12
+ export function revealSchedule(roles, beats = REVEAL_BEATS) {
13
+ const out = [];
14
+ let t = beats.lead;
15
+ roles.forEach((role, index) => {
16
+ if (role === "user") {
17
+ out.push({
18
+ at: t,
19
+ index,
20
+ phase: "done"
21
+ });
22
+ t += beats.user;
23
+ } else if (role === "assistant") {
24
+ out.push({
25
+ at: t,
26
+ index,
27
+ phase: "thinking"
28
+ });
29
+ t += beats.thinking;
30
+ out.push({
31
+ at: t,
32
+ index,
33
+ phase: "streaming"
34
+ });
35
+ t += beats.stream;
36
+ out.push({
37
+ at: t,
38
+ index,
39
+ phase: "done"
40
+ });
41
+ t += beats.after;
42
+ } else {
43
+ out.push({
44
+ at: t,
45
+ index,
46
+ phase: "running"
47
+ });
48
+ t += beats.running;
49
+ out.push({
50
+ at: t,
51
+ index,
52
+ phase: "done"
53
+ });
54
+ t += beats.after;
55
+ }
56
+ });
57
+ return {
58
+ beats: out,
59
+ end: t
60
+ };
61
+ }
62
+ /**
63
+ * Plays a conversation once the window is on screen and `active`, again each time `active` turns on.
64
+ * Renders finished for SSR, no JavaScript and reduced motion.
65
+ */
66
+ export function createReveal(options) {
67
+ let phases = $state([]);
68
+ let node = $state(null);
69
+ let seen = $state(false);
70
+ $effect(() => {
71
+ const { enabled } = options();
72
+ if (!node || !enabled || seen) return;
73
+ const io = new IntersectionObserver((entries) => {
74
+ if (entries.some((e) => e.isIntersecting)) {
75
+ seen = true;
76
+ io.disconnect();
77
+ }
78
+ }, { threshold: .35 });
79
+ io.observe(node);
80
+ return () => io.disconnect();
81
+ });
82
+ $effect(() => {
83
+ const { enabled, active } = options();
84
+ if (!enabled || !active || !seen) return;
85
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
86
+ const { roles, hold, ondone } = untrack(options);
87
+ const { beats, end } = revealSchedule(roles);
88
+ phases = roles.map(() => "hidden");
89
+ const timers = beats.map((b) => setTimeout(() => phases[b.index] = b.phase, b.at));
90
+ timers.push(setTimeout(() => ondone?.(), end + hold));
91
+ return () => {
92
+ timers.forEach(clearTimeout);
93
+ phases = [];
94
+ };
95
+ });
96
+ return {
97
+ /** A message's phase; `done` when nothing is playing. */
98
+ phase: (index) => phases[index] ?? "done",
99
+ /** Attach to the window's root, so the reveal waits until it scrolls into view. */
100
+ attach: (el) => {
101
+ node = el;
102
+ return () => node = null;
103
+ },
104
+ streamMs: REVEAL_BEATS.stream
105
+ };
106
+ }
@@ -0,0 +1,12 @@
1
+ import { getContext, setContext } from "svelte";
2
+ const CONFIRMATION_CONTEXT_KEY = Symbol("confirmation-context");
3
+ export function setConfirmationContext(value) {
4
+ setContext(CONFIRMATION_CONTEXT_KEY, value);
5
+ }
6
+ export function getConfirmationContext() {
7
+ const context = getContext(CONFIRMATION_CONTEXT_KEY);
8
+ if (!context) {
9
+ throw new Error("Confirmation parts must be used inside a <Confirmation>.");
10
+ }
11
+ return context;
12
+ }
@@ -0,0 +1,12 @@
1
+ import { getContext, setContext } from "svelte";
2
+ const REASONING_CONTEXT_KEY = Symbol("reasoning-context");
3
+ export function setReasoningContext(value) {
4
+ setContext(REASONING_CONTEXT_KEY, value);
5
+ }
6
+ export function getReasoningContext() {
7
+ const context = getContext(REASONING_CONTEXT_KEY);
8
+ if (!context) {
9
+ throw new Error("Reasoning parts must be used inside a <Reasoning>.");
10
+ }
11
+ return context;
12
+ }
@@ -0,0 +1,160 @@
1
+ import { applyOps } from "../ops/ops.js";
2
+ import { tryCatch } from "../../lib/result.js";
3
+ import { applyHistoryEntry, diffFlowState, mergeHistoryEntries } from "./history-core.js";
4
+ const MAX_ENTRIES = 200;
5
+ /** Keystrokes under this far apart share one undo step. */
6
+ const COALESCE_WINDOW_MS = 1e3;
7
+ // A drag whose stop never fires (pointer lost, node unmounted) leaves its transaction open,
8
+ // and drag frames never reach the dispatcher, so this counts from the last begin or commit.
9
+ const STUCK_TRANSACTION_MS = 1e4;
10
+ /** `Op` is the union of op shapes the caller dispatches: the canvas's own plus its plugins'. */
11
+ export function createCanvasDispatcher(io) {
12
+ const now = io.now ?? Date.now;
13
+ let undoStack = $state.raw([]);
14
+ let redoStack = $state.raw([]);
15
+ let depth = 0;
16
+ let baseline = null;
17
+ let lastActivityAt = 0;
18
+ // The text session the top undo entry belongs to, while keystrokes keep merging into it.
19
+ let session = null;
20
+ // Cards are replaced, never edited in place, so holding the objects equals a deep copy for a pointer each.
21
+ function snapshot() {
22
+ const state = io.getState();
23
+ return {
24
+ nodes: [...state.nodes],
25
+ edges: [...state.edges]
26
+ };
27
+ }
28
+ function isStale() {
29
+ return depth > 0 && now() - lastActivityAt > STUCK_TRANSACTION_MS;
30
+ }
31
+ /** Record the open transaction's diff as one history entry, merged into the text session it continues. */
32
+ function finish(coalesceKey) {
33
+ depth = 0;
34
+ const before = baseline;
35
+ baseline = null;
36
+ const entry = before && diffFlowState(before, io.getState());
37
+ if (!entry) return;
38
+ const at = now();
39
+ const top = undoStack.at(-1);
40
+ if (coalesceKey && top && session?.key === coalesceKey && at - session.at < COALESCE_WINDOW_MS) {
41
+ undoStack = [...undoStack.slice(0, -1), mergeHistoryEntries(top, entry)];
42
+ session = {
43
+ key: coalesceKey,
44
+ at
45
+ };
46
+ } else {
47
+ undoStack = [...undoStack.slice(-(MAX_ENTRIES - 1)), entry];
48
+ session = coalesceKey ? {
49
+ key: coalesceKey,
50
+ at
51
+ } : null;
52
+ }
53
+ redoStack = [];
54
+ io.onMutated();
55
+ }
56
+ /** Force-close a transaction nobody closed, so undo and the next gesture work again. */
57
+ function recoverStuck() {
58
+ if (isStale()) finish();
59
+ }
60
+ /** Open a transaction. Nested calls join the outermost one. */
61
+ function begin() {
62
+ recoverStuck();
63
+ lastActivityAt = now();
64
+ if (depth === 0) baseline = snapshot();
65
+ depth += 1;
66
+ }
67
+ /** Close a transaction. The outermost close diffs and records the entry. */
68
+ function commit(coalesceKey) {
69
+ lastActivityAt = now();
70
+ if (depth > 1) {
71
+ depth -= 1;
72
+ return;
73
+ }
74
+ // A commit with no begin has nothing to record.
75
+ if (depth === 1) finish(coalesceKey);
76
+ }
77
+ function run(fn, coalesceKey) {
78
+ begin();
79
+ const res = tryCatch(fn);
80
+ commit(coalesceKey);
81
+ if (!res.ok) throw res.error;
82
+ return res.value;
83
+ }
84
+ /**
85
+ * Apply granular ops to the flow state inside a transaction. A `coalesceKey` folds the
86
+ * step into the top undo entry while that key keeps committing under a second apart.
87
+ */
88
+ function dispatch(ops, options) {
89
+ const asked = Array.isArray(ops) ? ops : [ops];
90
+ const list = io.guard ? io.guard(io.getState().nodes, asked) : asked;
91
+ if (list.length === 0) return true;
92
+ return run(() => {
93
+ const state = io.getState();
94
+ const res = tryCatch(() => applyOps({
95
+ // `applyOps` patches plain records so one op log replays over xyflow
96
+ // and persisted shapes. Widening in and narrowing out is that seam.
97
+ nodes: state.nodes,
98
+ edges: state.edges
99
+ }, list, io.opDefinitions));
100
+ if (!res.ok) return false;
101
+ io.setState({
102
+ nodes: res.value.nodes,
103
+ edges: res.value.edges
104
+ });
105
+ return true;
106
+ }, options?.coalesceKey);
107
+ }
108
+ // An open gesture owns the baseline, so a replay under it would fold into its entry.
109
+ function undo() {
110
+ recoverStuck();
111
+ session = null;
112
+ const entry = undoStack.at(-1);
113
+ if (!entry || depth > 0) return;
114
+ undoStack = undoStack.slice(0, -1);
115
+ redoStack = [...redoStack, entry];
116
+ io.setState(applyHistoryEntry(io.getState(), entry, "undo"));
117
+ io.onMutated();
118
+ }
119
+ function redo() {
120
+ recoverStuck();
121
+ session = null;
122
+ const entry = redoStack.at(-1);
123
+ if (!entry || depth > 0) return;
124
+ redoStack = redoStack.slice(0, -1);
125
+ undoStack = [...undoStack, entry];
126
+ io.setState(applyHistoryEntry(io.getState(), entry, "redo"));
127
+ io.onMutated();
128
+ }
129
+ return {
130
+ get canUndo() {
131
+ return undoStack.length > 0;
132
+ },
133
+ get canRedo() {
134
+ return redoStack.length > 0;
135
+ },
136
+ /** True while a gesture holds a transaction open. A stuck one reads false. */
137
+ get isOpen() {
138
+ return depth > 0 && !isStale();
139
+ },
140
+ begin,
141
+ /** A long gesture calls this on every frame, so a slow drag isn't mistaken for a stuck one. */
142
+ touch() {
143
+ if (depth > 0) lastActivityAt = now();
144
+ },
145
+ commit: () => commit(),
146
+ run: (fn) => run(fn),
147
+ dispatch,
148
+ /** End the text session, so the next keystroke starts a new undo step. Call it on blur. */
149
+ endCoalesce() {
150
+ session = null;
151
+ },
152
+ undo,
153
+ redo,
154
+ clear() {
155
+ undoStack = [];
156
+ redoStack = [];
157
+ session = null;
158
+ }
159
+ };
160
+ }
@@ -0,0 +1,24 @@
1
+ import { untrack } from "svelte";
2
+ // One flag per canvas: a card reading it re-renders when it flips, not on every zoom frame.
3
+ /** Below this zoom a card is a box. */
4
+ export const LOW_BELOW = .4;
5
+ /** Back to the full card above this. The gap stops a zoom held at the line from swapping every card each frame. */
6
+ export const FULL_ABOVE = .46;
7
+ const modes = new WeakMap();
8
+ /** The shared flag for one canvas's store. The first card to ask starts the one effect that keeps it. */
9
+ export function lodMode(store) {
10
+ const known = modes.get(store);
11
+ if (known) return known;
12
+ const mode = $state({ low: store.viewport.zoom < LOW_BELOW });
13
+ modes.set(store, mode);
14
+ $effect.root(() => {
15
+ $effect(() => {
16
+ const zoom = store.viewport.zoom;
17
+ untrack(() => {
18
+ if (mode.low && zoom > FULL_ABOVE) mode.low = false;
19
+ else if (!mode.low && zoom < LOW_BELOW) mode.low = true;
20
+ });
21
+ });
22
+ });
23
+ return mode;
24
+ }
@@ -0,0 +1,42 @@
1
+ import { tick } from "svelte";
2
+ // An export reads the page's markup, so it holds this open: every card is drawn in full until it lets go.
3
+ const hold = $state({ count: 0 });
4
+ /** True while something needs every card drawn in full. */
5
+ export const renderAll = { get on() {
6
+ return hold.count > 0;
7
+ } };
8
+ const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
9
+ // Arrows draw a beat after their cards, once the cards are measured, so they are counted too.
10
+ const DRAWN = ".svelte-flow__node, .svelte-flow__edge";
11
+ const SETTLE_FRAMES = 8;
12
+ const GIVE_UP_MS = 4e3;
13
+ /** The page's card fade-in is switched off under this attribute, so a card that remounts is captured solid. */
14
+ export const EVERY_CARD_ATTR = "data-cv-every-card";
15
+ /** Runs `use` with every card drawn in full. Waits until the number of cards and arrows on the page holds still. */
16
+ export async function withEveryCard(root, use) {
17
+ hold.count += 1;
18
+ const marked = root instanceof Element ? root : null;
19
+ marked?.setAttribute(EVERY_CARD_ATTR, "");
20
+ try {
21
+ await tick();
22
+ const start = performance.now();
23
+ let last = -1;
24
+ let still = 0;
25
+ while (still < SETTLE_FRAMES && performance.now() - start < GIVE_UP_MS) {
26
+ await frame();
27
+ const now = root.querySelectorAll(DRAWN).length;
28
+ still = now === last ? still + 1 : 0;
29
+ last = now;
30
+ }
31
+ // A fade that began before the switch-off still has to land.
32
+ if ("getAnimations" in root) {
33
+ const running = root.getAnimations({ subtree: true });
34
+ await Promise.race([Promise.allSettled(running.map((animation) => animation.finished)), new Promise((resolve) => setTimeout(resolve, 1e3))]);
35
+ }
36
+ await frame();
37
+ return await use();
38
+ } finally {
39
+ hold.count -= 1;
40
+ if (hold.count === 0) marked?.removeAttribute(EVERY_CARD_ATTR);
41
+ }
42
+ }
@@ -0,0 +1,48 @@
1
+ // A pen node's accessible name. Svelte Flow puts `ariaLabel` on the focusable node wrapper,
2
+ // so the name has to live on the node object and follow its words as they're edited.
3
+ import { useStore } from "@xyflow/svelte";
4
+ import { untrack } from "svelte";
5
+ const cap = (word) => word.charAt(0).toUpperCase() + word.slice(1);
6
+ /** "Idea" for a text, "Rectangle: Idea" for a labelled shape, "Pen stroke" for a drawing. */
7
+ export function penNodeName(kind, data) {
8
+ if (kind === "text") return data.text?.trim() || "Empty text";
9
+ if (kind === "stroke") return `${cap(data.tool ?? "pen")} stroke`;
10
+ const shape = cap(data.shape ?? "shape");
11
+ const label = data.label?.trim();
12
+ return label ? `${shape}: ${label}` : shape;
13
+ }
14
+ // One write per canvas per tick: a write per node redrew the board 473 times opening 1,000 cards.
15
+ const pending = new WeakMap();
16
+ function queueName(store, nodeId, label) {
17
+ let names = pending.get(store);
18
+ if (!names) {
19
+ const queued = new Map();
20
+ names = queued;
21
+ pending.set(store, queued);
22
+ queueMicrotask(() => {
23
+ pending.delete(store);
24
+ let changed = false;
25
+ const nodes = store.nodes.map((node) => {
26
+ const name = queued.get(node.id);
27
+ if (name === undefined || node.ariaLabel === name) return node;
28
+ changed = true;
29
+ return {
30
+ ...node,
31
+ ariaLabel: name
32
+ };
33
+ });
34
+ if (changed) store.nodes = nodes;
35
+ });
36
+ }
37
+ names.set(nodeId, label);
38
+ }
39
+ /** Keeps node `id`'s ariaLabel equal to `name()`. Call it from a node component. */
40
+ export function useNodeName(id, name) {
41
+ const store = useStore();
42
+ $effect(() => {
43
+ const nodeId = id();
44
+ const label = name();
45
+ // untrack: the write reads the node list, and tracking it would rerun this on every write.
46
+ untrack(() => queueName(store, nodeId, label));
47
+ });
48
+ }
@@ -1,9 +1,11 @@
1
1
  // A pen node's accessible name. Svelte Flow puts `ariaLabel` on the focusable node wrapper,
2
2
  // so the name has to live on the node object and follow its words as they're edited.
3
3
 
4
- import { useSvelteFlow } from "@xyflow/svelte";
4
+ import { useStore } from "@xyflow/svelte";
5
5
  import { untrack } from "svelte";
6
6
 
7
+ type FlowStore = ReturnType<typeof useStore>;
8
+
7
9
  const cap = (word: string) => word.charAt(0).toUpperCase() + word.slice(1);
8
10
 
9
11
  /** "Idea" for a text, "Rectangle: Idea" for a labelled shape, "Pen stroke" for a drawing. */
@@ -18,15 +20,37 @@ export function penNodeName(
18
20
  return label ? `${shape}: ${label}` : shape;
19
21
  }
20
22
 
23
+ // One write per canvas per tick: a write per node redrew the board 473 times opening 1,000 cards.
24
+ const pending = new WeakMap<FlowStore, Map<string, string>>();
25
+
26
+ function queueName(store: FlowStore, nodeId: string, label: string): void {
27
+ let names = pending.get(store);
28
+ if (!names) {
29
+ const queued = new Map<string, string>();
30
+ names = queued;
31
+ pending.set(store, queued);
32
+ queueMicrotask(() => {
33
+ pending.delete(store);
34
+ let changed = false;
35
+ const nodes = store.nodes.map((node) => {
36
+ const name = queued.get(node.id);
37
+ if (name === undefined || node.ariaLabel === name) return node;
38
+ changed = true;
39
+ return { ...node, ariaLabel: name };
40
+ });
41
+ if (changed) store.nodes = nodes;
42
+ });
43
+ }
44
+ names.set(nodeId, label);
45
+ }
46
+
21
47
  /** Keeps node `id`'s ariaLabel equal to `name()`. Call it from a node component. */
22
48
  export function useNodeName(id: () => string, name: () => string): void {
23
- const { getNode, updateNode } = useSvelteFlow();
49
+ const store = useStore();
24
50
  $effect(() => {
25
51
  const nodeId = id();
26
52
  const label = name();
27
- // untrack: updateNode reads the node list, and tracking it would rerun this on every write.
28
- untrack(() => {
29
- if (getNode(nodeId)?.ariaLabel !== label) updateNode(nodeId, { ariaLabel: label });
30
- });
53
+ // untrack: the write reads the node list, and tracking it would rerun this on every write.
54
+ untrack(() => queueName(store, nodeId, label));
31
55
  });
32
56
  }
@@ -0,0 +1,34 @@
1
+ import { copyText } from "./clipboard.js";
2
+ /**
3
+ * The copied flag for a surface whose affordance is a whole row or card rather
4
+ * than a `CopyButton`. Keyed, so one instance serves a list of rows.
5
+ */
6
+ export class Copied {
7
+ /** The key that last copied, or null. Empty string when a surface has one. */
8
+ key = $state(null);
9
+ #timer = null;
10
+ /** True when anything copied; with a key, true only for that one. */
11
+ is(key = "") {
12
+ return this.key === key;
13
+ }
14
+ /** Writes, then holds the check for `hold` ms. False when the write failed. */
15
+ async copy(value, key = "", hold = 1400) {
16
+ if (!await copyText(value)) return false;
17
+ this.mark(key, hold);
18
+ return true;
19
+ }
20
+ /** The confirmation without the write, for a copy this class did not make. */
21
+ mark(key = "", hold = 1400) {
22
+ this.key = key;
23
+ if (this.#timer) clearTimeout(this.#timer);
24
+ this.#timer = setTimeout(() => {
25
+ this.key = null;
26
+ this.#timer = null;
27
+ }, hold);
28
+ }
29
+ reset() {
30
+ if (this.#timer) clearTimeout(this.#timer);
31
+ this.#timer = null;
32
+ this.key = null;
33
+ }
34
+ }