slidemind 0.3.19 → 0.4.0
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/engine-slides.mjs +1 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The spec's assets, fetched the same way by both clients: the CLI's
|
|
2
|
+
// `transcribe` and the add-in's slide preparation run THIS ladder, and each
|
|
3
|
+
// hands in only its own transport (search, download). Mirrored into the add-in
|
|
4
|
+
// by scripts/sync-transcription-runtime.mjs — no Node or CLI imports here.
|
|
5
|
+
import { pickIconResult, iconNameHits, iconSearchQueries, iconMotifSize } from "./transcribe.mjs";
|
|
6
|
+
|
|
7
|
+
// Transport failures are not evidence that a motif is absent. Retry the same
|
|
8
|
+
// operation, sharing a cooldown across the workers in one fetch batch.
|
|
9
|
+
export function engineFailure(error) {
|
|
10
|
+
const status = Number(error?.status ?? error?.statusCode);
|
|
11
|
+
const message = String(error?.message ?? error?.error ?? error);
|
|
12
|
+
const code = error?.code;
|
|
13
|
+
const limited = status === 429 || code === "rate_limited";
|
|
14
|
+
const transient = limited || error?.retryable === true || [408, 500, 502, 503, 504].includes(status) ||
|
|
15
|
+
/ECONNRESET|ETIMEDOUT|EAI_AGAIN|fetch failed|relay unreachable/i.test(message);
|
|
16
|
+
return { code: limited ? "rate_limited" : transient ? "transport_error" : "engine_error", retryable: transient, error: message };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function retryAfterMs(value, now = Date.now()) {
|
|
20
|
+
if (value == null || value === "") return null;
|
|
21
|
+
const seconds = Number(value);
|
|
22
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
23
|
+
const date = Date.parse(String(value));
|
|
24
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createEngineRetry({ sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), now = Date.now, attempts = 3, maxDelayMs = 120_000, onRetry = () => {} } = {}) {
|
|
28
|
+
let resumeAt = 0;
|
|
29
|
+
return async function retry(operation) {
|
|
30
|
+
for (let attempt = 1; ; attempt++) {
|
|
31
|
+
const wait = resumeAt - now();
|
|
32
|
+
if (wait > 0) await sleep(wait);
|
|
33
|
+
try {
|
|
34
|
+
const result = await operation();
|
|
35
|
+
if (result?.ok === false) {
|
|
36
|
+
throw Object.assign(new Error(result.error ?? "engine refused request"), result);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const failure = engineFailure(error);
|
|
41
|
+
const delay = error?.retryAfterMs ?? retryAfterMs(error?.headers?.["retry-after"], now()) ?? 2000 * 2 ** (attempt - 1);
|
|
42
|
+
// A rate limit is the engine asking for patience, not an outage: give
|
|
43
|
+
// it two more turns (x52: 26 icons reported missing were 429s).
|
|
44
|
+
const allowed = failure.code === "rate_limited" ? attempts + 2 : attempts;
|
|
45
|
+
if (!failure.retryable || attempt >= allowed || delay > maxDelayMs) {
|
|
46
|
+
throw Object.assign(new Error(failure.error), failure, { attempts: attempt, ...(failure.retryable ? { retryAfterMs: delay } : {}) });
|
|
47
|
+
}
|
|
48
|
+
resumeAt = Math.max(resumeAt, now() + delay);
|
|
49
|
+
onRetry({ ...failure, attempt, delay });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const engineText = (e) => `${e?.label ?? e?.name ?? ""} ${e?.description ?? ""} ${e?.kind ?? ""}`;
|
|
56
|
+
const PHOTO = /photo|photograph|illustration/i;
|
|
57
|
+
const ICON = /icon|pictogram|vector/i;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The image engine for one row, chosen from the catalog's descriptive evidence
|
|
61
|
+
* (name, description), never from a provider- or brand-specific id. `engines`
|
|
62
|
+
* are the caller's searchable image engines, in catalog order; the order breaks
|
|
63
|
+
* ties, so a design with one image engine always gets that one.
|
|
64
|
+
*/
|
|
65
|
+
export function selectAssetEngine(engines, kind) {
|
|
66
|
+
const list = Array.isArray(engines) ? engines : [];
|
|
67
|
+
if (kind !== "image") return list.find((e) => ICON.test(engineText(e))) ?? list[0] ?? null;
|
|
68
|
+
return list.find((e) => PHOTO.test(engineText(e))) ?? list.find((e) => !ICON.test(engineText(e))) ?? list[0] ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A glyph is recoloured to the paint the transcriber resolved; a photo never is. */
|
|
72
|
+
export function assetRecolor(group, engine) {
|
|
73
|
+
if (group.kind === "icon") return group.colorHex ?? undefined;
|
|
74
|
+
return PHOTO.test(engineText(engine)) ? undefined : group.tintHex ?? undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fetch every row's asset: one search ladder + one download per distinct
|
|
79
|
+
* (kind, query, colour); rows sharing it share the result.
|
|
80
|
+
*
|
|
81
|
+
* - `search({ engine, query })` → `{ searchId, results: [{ selection, title|sourceUrl }], collage? }`.
|
|
82
|
+
* `collage` is the numbered contact sheet of the results (data URI), when
|
|
83
|
+
* the transport has one. Throw (or return `{ ok: false }`) on a failed request.
|
|
84
|
+
* - `pick({ group, query, results, collage })` (optional) → the 1-based
|
|
85
|
+
* selection a vision reader chose from the collage, or null when nothing in
|
|
86
|
+
* it shows the motif. File names are often cryptic, so with a picker the
|
|
87
|
+
* collage decides; a name that fully spells the motif still skips the call.
|
|
88
|
+
* A thrown pick is no verdict: that rung falls back to the file names.
|
|
89
|
+
* - `download({ engine, searchId, selection, recolor, group })` → the caller's
|
|
90
|
+
* handle for the bytes (truthy), or throw.
|
|
91
|
+
*
|
|
92
|
+
* Returns one entry per group: `{ group, engine, result, failure }` — exactly one of
|
|
93
|
+
* `result` / `failure` is set. A miss never removes anything; the caller keeps
|
|
94
|
+
* the unresolved reference and reports it.
|
|
95
|
+
*/
|
|
96
|
+
export async function fetchAssetGroups({ rows, engines, search, download, pick = null, retry = createEngineRetry(), onError = () => {}, concurrency = 6 }) {
|
|
97
|
+
const groups = new Map();
|
|
98
|
+
for (const r of rows ?? []) {
|
|
99
|
+
const key = `${r.kind ?? "icon"}|${String(r.query).toLowerCase()}|${r.colorHex ?? "source"}`;
|
|
100
|
+
if (!groups.has(key)) groups.set(key, { query: r.query, keywords: r.keywords ?? [], kind: r.kind ?? "icon", color: r.color, colorHex: r.colorHex, tintHex: r.tintHex ?? null, rows: [] });
|
|
101
|
+
groups.get(key).rows.push(r);
|
|
102
|
+
}
|
|
103
|
+
const work = [...groups.values()];
|
|
104
|
+
const out = [];
|
|
105
|
+
// undefined = no verdict (no picker, no collage, picker failed).
|
|
106
|
+
const visualPick = async (g, query, res, results) => {
|
|
107
|
+
if (!pick || !res.collage) return undefined;
|
|
108
|
+
try {
|
|
109
|
+
const selection = await pick({ group: g, query, results, collage: res.collage });
|
|
110
|
+
return Number.isInteger(selection) && results.some((r, i) => (r.selection ?? i + 1) === selection) ? selection : null;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
onError(query, err);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
let cursor = 0;
|
|
117
|
+
const worker = async () => {
|
|
118
|
+
for (;;) {
|
|
119
|
+
const g = work[cursor++];
|
|
120
|
+
if (!g) return;
|
|
121
|
+
const engine = selectAssetEngine(engines, g.kind);
|
|
122
|
+
if (!engine) { out.push({ group: g, engine: null, result: null, failure: { code: "no_engine", retryable: false, error: "no compatible image engine" } }); continue; }
|
|
123
|
+
let result = null;
|
|
124
|
+
let failure = { code: "no_semantic_match", retryable: false, error: "No engine result matched this motif; source another motif for what the element stands for, never an unrelated one." };
|
|
125
|
+
// Strip style words without losing compound motifs. Transport retries
|
|
126
|
+
// repeat the identical request and are separate from query refinement.
|
|
127
|
+
const queries = g.kind === "icon" ? iconSearchQueries(g.query, g.keywords) : [g.query];
|
|
128
|
+
// The best candidate seen so far, across attempts: the engine's ranking
|
|
129
|
+
// is noise (nine results inside 0.002), so the file name decides, and a
|
|
130
|
+
// set where NO name carries a query word is nine unrelated glyphs — the
|
|
131
|
+
// next, shorter query gets a different nine. A first attempt that fully
|
|
132
|
+
// names the motif stops the ladder, so a well-named motif costs one search.
|
|
133
|
+
let best = null;
|
|
134
|
+
// Keep climbing while the best set only half-names the motif ("shield
|
|
135
|
+
// check" → a plain shield stopped the ladder at attempt 0; x54: ~9 of 12
|
|
136
|
+
// slides carried such substitutes). The best set across attempts wins.
|
|
137
|
+
const full = g.kind === "image" ? 1 : iconMotifSize(g.query);
|
|
138
|
+
for (let attempt = 0; attempt < queries.length && !result; attempt++) {
|
|
139
|
+
try {
|
|
140
|
+
const res = await retry(() => search({ engine, query: queries[attempt] }));
|
|
141
|
+
const results = Array.isArray(res?.results) ? res.results : [];
|
|
142
|
+
if (res?.searchId && results.length) {
|
|
143
|
+
const named = g.kind === "image" ? null : pickIconResult(g.query, results, g.keywords);
|
|
144
|
+
const hits = named ? iconNameHits(g.query, named, g.keywords) : 0;
|
|
145
|
+
// A name that spells the whole motif needs no second opinion.
|
|
146
|
+
const verdict = named && hits >= full ? undefined : await visualPick(g, queries[attempt], res, results);
|
|
147
|
+
if (verdict) best = { hits: full, searchId: res.searchId, selection: verdict };
|
|
148
|
+
else if (verdict === undefined) {
|
|
149
|
+
const byName = g.kind === "image" ? results[0] : named;
|
|
150
|
+
const h = g.kind === "image" ? 1 : hits;
|
|
151
|
+
if (byName && (!best || h > best.hits)) best = { hits: h, searchId: res.searchId, selection: byName.selection ?? 1 };
|
|
152
|
+
}
|
|
153
|
+
// null: the reader looked and nothing here shows the motif.
|
|
154
|
+
}
|
|
155
|
+
if (!best || (best.hits < full && attempt < queries.length - 1)) continue;
|
|
156
|
+
result = await retry(() => download({ engine, searchId: best.searchId, selection: best.selection, recolor: assetRecolor(g, engine), group: g }));
|
|
157
|
+
if (!result) throw Object.assign(new Error("engine download returned no local artifact"), { code: "invalid_download" });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
result = null;
|
|
160
|
+
failure = { ...engineFailure(err), code: err?.code ?? engineFailure(err).code, attempts: err?.attempts, retryAfterMs: err?.retryAfterMs };
|
|
161
|
+
onError(queries[attempt], err);
|
|
162
|
+
// Do not try a different motif when the service is unavailable.
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
out.push(result ? { group: g, engine, result, failure: null } : { group: g, engine, result: null, failure });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, work.length) }, worker));
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Coalesce concurrent single calls into one batched call: every `call(input)`
|
|
175
|
+
* made within `waitMs` of the first (up to `max`) travels as one
|
|
176
|
+
* `run(inputs)`, which answers with one output per input in order — an `Error`
|
|
177
|
+
* in a slot rejects only that caller. The ladders run concurrently, so their
|
|
178
|
+
* searches and picks meet here: one engine request and one vision read per
|
|
179
|
+
* round instead of one per motif.
|
|
180
|
+
*/
|
|
181
|
+
export function createBatcher(run, { max = 8, waitMs = 25 } = {}) {
|
|
182
|
+
let queue = [];
|
|
183
|
+
let timer = null;
|
|
184
|
+
const flush = () => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
timer = null;
|
|
187
|
+
const batch = queue.slice(0, max);
|
|
188
|
+
queue = queue.slice(max);
|
|
189
|
+
if (queue.length) timer = setTimeout(flush, 0);
|
|
190
|
+
Promise.resolve()
|
|
191
|
+
.then(() => run(batch.map((b) => b.input)))
|
|
192
|
+
.then(
|
|
193
|
+
(outs) => batch.forEach((b, i) => (outs?.[i] instanceof Error ? b.reject(outs[i]) : b.resolve(outs?.[i]))),
|
|
194
|
+
(err) => batch.forEach((b) => b.reject(err)),
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
return (input) =>
|
|
198
|
+
new Promise((resolve, reject) => {
|
|
199
|
+
queue.push({ input, resolve, reject });
|
|
200
|
+
if (queue.length >= max) flush();
|
|
201
|
+
else if (!timer) timer = setTimeout(flush, waitMs);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A divider drawn between two lines of ONE text block — the rule under a
|
|
2
|
+
// card's title, over its body (x105 GTM 2 / cloud 2: 34 such rules in 21
|
|
3
|
+
// specs; 86 across x101–x105). The reader gives the lines one `block` and the
|
|
4
|
+
// rule as its own stroke; foldTextBlocks writes the lines as one `T` with a
|
|
5
|
+
// paragraph each, so the rule belongs to the BOUNDARY between two paragraphs
|
|
6
|
+
// of that block: when the block's measured text is taller or shorter than the
|
|
7
|
+
// painting, the rule rides with that boundary instead of keeping its painted
|
|
8
|
+
// y (where it ended up under the body). The relation is containment in a
|
|
9
|
+
// declared structure — the block's lines define the boundary, the rule lies
|
|
10
|
+
// in it — not symmetry read into geometry; it keeps its drawn fraction of the
|
|
11
|
+
// gap between the two paragraphs.
|
|
12
|
+
import { linePoints, moveLine as moveLineDefault } from "./repeat-groups.mjs";
|
|
13
|
+
|
|
14
|
+
const half = (v) => Math.round(v * 2) / 2;
|
|
15
|
+
|
|
16
|
+
const horizontal = (c) => {
|
|
17
|
+
const pts = linePoints(c);
|
|
18
|
+
if (pts.length < 2) return null;
|
|
19
|
+
const ys = pts.map((p) => p.y);
|
|
20
|
+
if (Math.max(...ys) - Math.min(...ys) > 2) return null;
|
|
21
|
+
return { y: (Math.min(...ys) + Math.max(...ys)) / 2, x0: Math.min(...pts.map((p) => p.x)), x1: Math.max(...pts.map((p) => p.x)) };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The dividers drawn between consecutive `lines` (sorted, the members of one
|
|
26
|
+
* block, each contributing `count(line)` paragraphs): `[{ id, after, frac }]`
|
|
27
|
+
* with `after` the index of the paragraph the divider follows and `frac` its
|
|
28
|
+
* drawn fraction of the gap to the next one. Lines are connectors with an id.
|
|
29
|
+
*/
|
|
30
|
+
export function recordBlockDividers(lines, connectors, count = () => 1) {
|
|
31
|
+
const x0 = Math.min(...lines.map((c) => c.x));
|
|
32
|
+
const x1 = Math.max(...lines.map((c) => c.x + c.w));
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const c of connectors) {
|
|
35
|
+
if (!c?.id) continue;
|
|
36
|
+
const h = horizontal(c);
|
|
37
|
+
if (!h || !(h.x0 < x1 && x0 < h.x1)) continue;
|
|
38
|
+
let after = 0;
|
|
39
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
40
|
+
after += count(lines[i]);
|
|
41
|
+
const a = lines[i].y + lines[i].h;
|
|
42
|
+
const b = lines[i + 1].y;
|
|
43
|
+
if (h.y >= a - 1 && h.y <= b + 1) {
|
|
44
|
+
out.push({ id: c.id, after: after - 1, frac: b > a ? Math.min(1, Math.max(0, (h.y - a) / (b - a))) : 0.5 });
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Place every block divider at its boundary on the MEASURED paragraphs
|
|
54
|
+
* (`measured.get(block).paraRects`, settle.mjs measuredFromRender). Returns
|
|
55
|
+
* one line per divider moved. `moveLine(c, dx, dy)` is the caller's.
|
|
56
|
+
*/
|
|
57
|
+
export function placeBlockDividers(spec, measured, { moveLine = moveLineDefault } = {}) {
|
|
58
|
+
const changes = [];
|
|
59
|
+
const byId = new Map((spec.connectors ?? []).filter((c) => c?.id).map((c) => [c.id, c]));
|
|
60
|
+
for (const e of spec.elements ?? []) {
|
|
61
|
+
if (!e.dividers?.length) continue;
|
|
62
|
+
const m = measured.get(e.id);
|
|
63
|
+
const rects = m?.paraRects;
|
|
64
|
+
// Only when the render shows one box per paragraph: anything else is left where it is.
|
|
65
|
+
if (!rects || rects.length !== (e.paragraphs?.length ?? 0)) continue;
|
|
66
|
+
for (const d of e.dividers) {
|
|
67
|
+
const c = byId.get(d.id);
|
|
68
|
+
const a = rects[d.after];
|
|
69
|
+
const b = rects[d.after + 1];
|
|
70
|
+
if (!c || !a || !b) continue;
|
|
71
|
+
const bottom = a.y + a.h;
|
|
72
|
+
const next = b.y;
|
|
73
|
+
const want = half(bottom + Math.max(0, next - bottom) * d.frac);
|
|
74
|
+
const h = horizontal(c);
|
|
75
|
+
if (!h) continue;
|
|
76
|
+
const dy = half(want - h.y);
|
|
77
|
+
if (!dy) continue;
|
|
78
|
+
moveLine(c, 0, dy);
|
|
79
|
+
changes.push(`${d.id}: ${dy >= 0 ? "+" : ""}${dy}px to its place between the paragraphs of ${e.id}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return changes;
|
|
83
|
+
}
|