@quario/editor 0.1.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/CHANGELOG.md +39 -0
- package/LICENSE +219 -0
- package/README.md +89 -0
- package/lib/chrome.js +431 -0
- package/lib/document.js +674 -0
- package/lib/history.js +57 -0
- package/lib/index.d.ts +119 -0
- package/lib/index.js +820 -0
- package/lib/rail.js +506 -0
- package/lib/register.d.ts +9 -0
- package/lib/register.js +10 -0
- package/lib/stage.js +654 -0
- package/lib/style.js +85 -0
- package/package.json +77 -0
package/lib/document.js
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor's document model — one pure module, no DOM, the seam the Node
|
|
3
|
+
* suite pins. The document is exactly the schema: plain JSON, no injected ids,
|
|
4
|
+
* addressed by the engine's own path grammar. Every operation takes a schema
|
|
5
|
+
* and returns a new one (the history is a capped snapshot stack, so an op
|
|
6
|
+
* produces a document rather than mutating one), and legality is derived from
|
|
7
|
+
* the schema's closed key sets plus the engine's per-node anchor sets — a drop
|
|
8
|
+
* is legal only when every anchor the item reads is bound in the destination
|
|
9
|
+
* band. (The safety suite greps every source file for the forbidden
|
|
10
|
+
* string-to-code constructs, so they must not appear even in a comment.)
|
|
11
|
+
*
|
|
12
|
+
* Two rules hold everywhere:
|
|
13
|
+
*
|
|
14
|
+
* - Never normalize absent to empty, or back. `"empty": []` suppresses the
|
|
15
|
+
* body while an absent `empty` keeps the default, so tidying a key away when
|
|
16
|
+
* its last item leaves would silently change the report. Removing the last
|
|
17
|
+
* item leaves `[]`; removing a band is a separate explicit verb.
|
|
18
|
+
* - Touch only the key you edited. Style declaration order survives a
|
|
19
|
+
* write-back, clearing an optional key removes it while a required key
|
|
20
|
+
* writes `""`, and a column header widens for style but never narrows.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// --- paths ----------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** @type {(path: string) => ({ key: string } | { index: number })[] | null} */
|
|
26
|
+
let segments = (path) => {
|
|
27
|
+
let out = [];
|
|
28
|
+
for (let part of path.split(".")) {
|
|
29
|
+
let m = part.match(/^([^[]+)((\[\d+\])*)$/);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
out.push({ key: m[1] });
|
|
32
|
+
for (let idx of m[2].matchAll(/\[(\d+)\]/g)) out.push({ index: Number(idx[1]) });
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** One resolution step: follow the segment, or stay undefined off the end. */
|
|
38
|
+
/** @type {(node: any, seg: { key: string } | { index: number }) => any} */
|
|
39
|
+
let step = (node, seg) =>
|
|
40
|
+
node == null ? undefined : "key" in seg ? node[seg.key] : node[seg.index];
|
|
41
|
+
|
|
42
|
+
/** Resolve a schema path to the node behind it, or undefined. */
|
|
43
|
+
/** @type {(schema: any, path: string) => any} */
|
|
44
|
+
export let resolve = (schema, path) => {
|
|
45
|
+
let segs = segments(path);
|
|
46
|
+
if (!segs) return undefined;
|
|
47
|
+
let node = schema;
|
|
48
|
+
for (let seg of segs) node = step(node, seg);
|
|
49
|
+
return node;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** `groups[0].header[2]` -> `groups[0].header`; `header[0]` -> `header`. */
|
|
53
|
+
/** @type {(path: string) => string} */
|
|
54
|
+
export let bandOfItem = (path) => path.replace(/\[\d+\]$/, "");
|
|
55
|
+
|
|
56
|
+
/** The item index a path ends with, or -1. */
|
|
57
|
+
/** @type {(path: string) => number} */
|
|
58
|
+
export let indexOfItem = (path) => Number(path.match(/\[(\d+)\]$/)?.[1] ?? -1);
|
|
59
|
+
|
|
60
|
+
// --- bands and the slot model ---------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** `$` is bound everywhere; the rest is positional. */
|
|
63
|
+
let ALWAYS = ["$"];
|
|
64
|
+
|
|
65
|
+
/** The report's groups, outermost first, or [] when it declares none. */
|
|
66
|
+
/** @type {(schema: any) => any[]} */
|
|
67
|
+
let groupsOf = (schema) => (Array.isArray(schema?.groups) ? schema.groups : []);
|
|
68
|
+
|
|
69
|
+
/** @type {(schema: any) => string[]} */
|
|
70
|
+
let groupNamesOf = (schema) =>
|
|
71
|
+
groupsOf(schema)
|
|
72
|
+
.map((/** @type {any} */ group) => group?.name)
|
|
73
|
+
.filter((/** @type {any} */ name) => typeof name === "string");
|
|
74
|
+
|
|
75
|
+
/** Whether the detail band is a table rather than a stack of items. */
|
|
76
|
+
/** @type {(schema: any) => boolean} */
|
|
77
|
+
let isTable = (schema) => schema?.detail != null && !Array.isArray(schema.detail);
|
|
78
|
+
|
|
79
|
+
/** The anchors a band inside group `i` binds: `$` plus every enclosing handle. */
|
|
80
|
+
/** @type {(groups: any[], i: number) => string[]} */
|
|
81
|
+
let bindsAt = (groups, i) => [...ALWAYS, ...groupNamesOf({ groups: groups.slice(0, i + 1) })];
|
|
82
|
+
|
|
83
|
+
/** One group instance's own header or footer band. */
|
|
84
|
+
/** @type {(group: any, i: number, which: "header" | "footer", binds: string[]) => any} */
|
|
85
|
+
let groupBand = (group, i, which, binds) => ({
|
|
86
|
+
path: "groups[" + i + "]." + which,
|
|
87
|
+
role: "group-" + which,
|
|
88
|
+
label: group?.name + " " + which,
|
|
89
|
+
binds,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The bands of a report, in render order, each with the anchors it binds.
|
|
94
|
+
* `page.header`/`page.footer` and `empty` are deliberately absent: outside
|
|
95
|
+
* v0.1's editable surface (they are invisible in an html-rendered preview).
|
|
96
|
+
*
|
|
97
|
+
* @type {(schema: any) => { path: string, role: string, label: string,
|
|
98
|
+
* binds: string[], table?: boolean }[]}
|
|
99
|
+
*/
|
|
100
|
+
export let bands = (schema) => {
|
|
101
|
+
let groups = groupsOf(schema);
|
|
102
|
+
/** @type {ReturnType<typeof bands>} */
|
|
103
|
+
let out = [{ path: "header", role: "report-header", label: "Report header", binds: [...ALWAYS] }];
|
|
104
|
+
groups.forEach((/** @type {any} */ group, /** @type {number} */ i) =>
|
|
105
|
+
out.push(groupBand(group, i, "header", bindsAt(groups, i))),
|
|
106
|
+
);
|
|
107
|
+
// The detail band is the only place `@` and `run` are bound, and every
|
|
108
|
+
// group handle is still in scope there.
|
|
109
|
+
out.push({
|
|
110
|
+
path: "detail",
|
|
111
|
+
role: "detail",
|
|
112
|
+
label: "Detail",
|
|
113
|
+
binds: [...ALWAYS, ...groupNamesOf(schema), "@", "run"],
|
|
114
|
+
table: isTable(schema),
|
|
115
|
+
});
|
|
116
|
+
for (let i = groups.length - 1; i >= 0; i--)
|
|
117
|
+
out.push(groupBand(groups[i], i, "footer", bindsAt(groups, i)));
|
|
118
|
+
out.push({ path: "footer", role: "report-footer", label: "Report footer", binds: [...ALWAYS] });
|
|
119
|
+
return out;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** The items a band holds, or [] for a band that is absent or a table. */
|
|
123
|
+
/** @type {(schema: any, bandPath: string) => any[]} */
|
|
124
|
+
export let itemsIn = (schema, bandPath) => {
|
|
125
|
+
let value = resolve(schema, bandPath);
|
|
126
|
+
return Array.isArray(value) ? value : [];
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Whether a compiled source's path is the node's own or sits under it. */
|
|
130
|
+
/** @type {(at: string, path: string) => boolean} */
|
|
131
|
+
let under = (at, path) => at === path || at.startsWith(path + ".");
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The anchors a node reads, unioned from the engine plan's per-source sets —
|
|
135
|
+
* `anchors` is `plan().anchors`, keyed by compiled-source path, so the node's
|
|
136
|
+
* own set is every entry at or under its path. The engine's answer, never a
|
|
137
|
+
* source scan: it compiled the cell, so it knows.
|
|
138
|
+
*
|
|
139
|
+
* @type {(anchors: Record<string, readonly string[]>, path: string) => Set<string>}
|
|
140
|
+
*/
|
|
141
|
+
export let anchorsReadBy = (anchors, path) => {
|
|
142
|
+
let out = new Set();
|
|
143
|
+
for (let [at, names] of Object.entries(anchors))
|
|
144
|
+
if (under(at, path)) for (let name of names) out.add(name);
|
|
145
|
+
return out;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** The two slots an item already occupies — offering them reads as a no-op. */
|
|
149
|
+
/** @type {(band: any, index: number, held: { band: string, at: number }) => boolean} */
|
|
150
|
+
let ownSlot = (band, index, held) =>
|
|
151
|
+
band.path === held.band && (index === held.at || index === held.at + 1);
|
|
152
|
+
|
|
153
|
+
/** Every insertion point one band offers the item, with its verdict. */
|
|
154
|
+
/** @type {(schema: any, band: any, reads: Set<string>,
|
|
155
|
+
* held: { band: string, at: number }) => ReturnType<typeof legalSlots>} */
|
|
156
|
+
let slotsOf = (schema, band, reads, held) => {
|
|
157
|
+
let blocked = [...reads].filter((anchor) => !band.binds.includes(anchor));
|
|
158
|
+
let count = itemsIn(schema, band.path).length;
|
|
159
|
+
/** @type {ReturnType<typeof legalSlots>} */
|
|
160
|
+
let out = [];
|
|
161
|
+
for (let index = 0; index <= count; index++)
|
|
162
|
+
if (!ownSlot(band, index, held))
|
|
163
|
+
out.push({ band: band.path, label: band.label, index, legal: !blocked.length, blocked });
|
|
164
|
+
return out;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Every slot the item at `path` may be dropped into, as insertion points:
|
|
169
|
+
* index 0 is before the band's first item, index n after its last. The item's
|
|
170
|
+
* own two adjacent slots are excluded — they are where it already is. A slot
|
|
171
|
+
* is refused (`legal: false`, with the anchors that block it) when the band
|
|
172
|
+
* does not bind everything the item reads; a table takes columns, never items.
|
|
173
|
+
*
|
|
174
|
+
* @type {(schema: any, anchors: Record<string, readonly string[]>, path: string)
|
|
175
|
+
* => { band: string, label: string, index: number, legal: boolean, blocked: string[] }[]}
|
|
176
|
+
*/
|
|
177
|
+
export let legalSlots = (schema, anchors, path) => {
|
|
178
|
+
let reads = anchorsReadBy(anchors, path);
|
|
179
|
+
let held = { band: bandOfItem(path), at: indexOfItem(path) };
|
|
180
|
+
/** @type {ReturnType<typeof legalSlots>} */
|
|
181
|
+
let out = [];
|
|
182
|
+
for (let band of bands(schema)) if (!band.table) out.push(...slotsOf(schema, band, reads, held));
|
|
183
|
+
return out;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// --- item operations -------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** @type {(schema: any, bandPath: string) => any[]} */
|
|
189
|
+
let bandArray = (next, bandPath) => {
|
|
190
|
+
// A drop or insert into an absent band is a creation, so the key appears;
|
|
191
|
+
// nothing else ever conjures or removes a band key.
|
|
192
|
+
let m = bandPath.match(/^groups\[(\d+)\]\.(header|footer)$/);
|
|
193
|
+
if (m) {
|
|
194
|
+
let group = next.groups[+m[1]];
|
|
195
|
+
group[m[2]] = Array.isArray(group[m[2]]) ? group[m[2]] : [];
|
|
196
|
+
return group[m[2]];
|
|
197
|
+
}
|
|
198
|
+
next[bandPath] = Array.isArray(next[bandPath]) ? next[bandPath] : [];
|
|
199
|
+
return next[bandPath];
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** Move the item at `path` into `slot` (`{ band, index }`). */
|
|
203
|
+
/** @type {(schema: any, path: string, slot: { band: string, index: number }) => any} */
|
|
204
|
+
export let moveItem = (schema, path, slot) => {
|
|
205
|
+
let next = structuredClone(schema);
|
|
206
|
+
let from = bandOfItem(path);
|
|
207
|
+
let at = indexOfItem(path);
|
|
208
|
+
let src = bandArray(next, from);
|
|
209
|
+
let [node] = src.splice(at, 1);
|
|
210
|
+
let dst = bandArray(next, slot.band);
|
|
211
|
+
// Removing from an earlier position in the same band shifts the target left.
|
|
212
|
+
let index = from === slot.band && at < slot.index ? slot.index - 1 : slot.index;
|
|
213
|
+
dst.splice(index, 0, node);
|
|
214
|
+
return next;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Insert a new text item — the one item kind whose required keys all have
|
|
219
|
+
* neutral defaults, which is the insertable-with-defaults rule that keeps
|
|
220
|
+
* images (expression-only source) out of the catalogue.
|
|
221
|
+
*/
|
|
222
|
+
/** @type {(schema: any, band: string, index: number) => any} */
|
|
223
|
+
export let insertItem = (schema, band, index) => {
|
|
224
|
+
let next = structuredClone(schema);
|
|
225
|
+
bandArray(next, band).splice(index, 0, { type: "text", value: "" });
|
|
226
|
+
return next;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** Remove the item at `path`. Removing the last item leaves `[]`. */
|
|
230
|
+
/** @type {(schema: any, path: string) => any} */
|
|
231
|
+
export let removeItem = (schema, path) => {
|
|
232
|
+
let next = structuredClone(schema);
|
|
233
|
+
bandArray(next, bandOfItem(path)).splice(indexOfItem(path), 1);
|
|
234
|
+
return next;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Duplicate the item at `path`, the copy landing right after it. */
|
|
238
|
+
/** @type {(schema: any, path: string) => any} */
|
|
239
|
+
export let duplicateItem = (schema, path) => {
|
|
240
|
+
let next = structuredClone(schema);
|
|
241
|
+
let list = bandArray(next, bandOfItem(path));
|
|
242
|
+
let at = indexOfItem(path);
|
|
243
|
+
list.splice(at + 1, 0, structuredClone(list[at]));
|
|
244
|
+
return next;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// --- column operations ------------------------------------------------------
|
|
248
|
+
//
|
|
249
|
+
// Every column operation mirrors `detail.total` silently: it is index-aligned
|
|
250
|
+
// to `columns`, so a move, insert, remove or duplicate that touched one and
|
|
251
|
+
// not the other would silently misalign the totals row.
|
|
252
|
+
|
|
253
|
+
/** @type {(next: any, run: (list: any[]) => void) => void} */
|
|
254
|
+
let mirrorTotal = (next, run) => {
|
|
255
|
+
run(next.detail.columns);
|
|
256
|
+
if (Array.isArray(next.detail.total)) run(next.detail.total);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// The engine's own invariant (SCHEMA.md, "Table detail"): sized widths total
|
|
260
|
+
// at most 100 when every column is sized, and strictly under 100 otherwise,
|
|
261
|
+
// leaving the width-less columns room. `LIMIT` is what breaks it; `TARGET` is
|
|
262
|
+
// what a rescale aims at — an equal average share for the columns that have
|
|
263
|
+
// no width. They differ on purpose: widths that are already legal are never
|
|
264
|
+
// touched, which is the whole of "only when that breaks the sum invariant".
|
|
265
|
+
/** @type {(sized: number, columns: number) => number} */
|
|
266
|
+
let limit = (sized, columns) => (sized === columns ? 100 : 100 - WIDTH_EPS);
|
|
267
|
+
/** @type {(sized: number, columns: number) => number} */
|
|
268
|
+
let target = (sized, columns) => (sized === columns ? 100 : (100 * sized) / columns);
|
|
269
|
+
|
|
270
|
+
/** The engine's own tolerance, restated: widths an author wrote as thirds
|
|
271
|
+
* must not fail on IEEE 754 alone. */
|
|
272
|
+
let WIDTH_EPS = 1e-9;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Scale authored widths down proportionally when an added column breaks the
|
|
276
|
+
* sum invariant — the one operation where the editor edits nodes the author
|
|
277
|
+
* did not touch. The invariant is the engine's: sized widths total at most
|
|
278
|
+
* 100 when every column is sized, and under 100 (leaving room) otherwise.
|
|
279
|
+
* The allowance leaves each width-less column an equal average share.
|
|
280
|
+
*/
|
|
281
|
+
/** @type {(columns: any[]) => void} */
|
|
282
|
+
let fitWidths = (columns) => {
|
|
283
|
+
let sized = columns.filter((column) => column.width != null);
|
|
284
|
+
let total = sized.reduce((all, column) => all + column.width, 0);
|
|
285
|
+
if (total <= limit(sized.length, columns.length)) return;
|
|
286
|
+
let allowed = target(sized.length, columns.length);
|
|
287
|
+
for (let column of sized) column.width = (column.width * allowed) / total;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** Move the column at index `at` to insertion point `to`. */
|
|
291
|
+
/** @type {(schema: any, at: number, to: number) => any} */
|
|
292
|
+
export let moveColumn = (schema, at, to) => {
|
|
293
|
+
let next = structuredClone(schema);
|
|
294
|
+
let index = at < to ? to - 1 : to;
|
|
295
|
+
mirrorTotal(next, (list) => list.splice(index, 0, list.splice(at, 1)[0]));
|
|
296
|
+
return next;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/** Insert a new column (and its empty total cell) at insertion point `index`. */
|
|
300
|
+
/** @type {(schema: any, index: number) => any} */
|
|
301
|
+
export let insertColumn = (schema, index) => {
|
|
302
|
+
let next = structuredClone(schema);
|
|
303
|
+
next.detail.columns.splice(index, 0, { header: "", value: "" });
|
|
304
|
+
if (Array.isArray(next.detail.total)) next.detail.total.splice(index, 0, { value: "" });
|
|
305
|
+
fitWidths(next.detail.columns);
|
|
306
|
+
return next;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** Remove the column at `index` — never the last: `columns` is non-empty. */
|
|
310
|
+
/** @type {(schema: any, index: number) => any} */
|
|
311
|
+
export let removeColumn = (schema, index) => {
|
|
312
|
+
if (schema.detail.columns.length <= 1) return schema;
|
|
313
|
+
let next = structuredClone(schema);
|
|
314
|
+
mirrorTotal(next, (list) => list.splice(index, 1));
|
|
315
|
+
return next;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** Duplicate the column at `index` (and its total cell), landing after it. */
|
|
319
|
+
/** @type {(schema: any, index: number) => any} */
|
|
320
|
+
export let duplicateColumn = (schema, index) => {
|
|
321
|
+
let next = structuredClone(schema);
|
|
322
|
+
mirrorTotal(next, (list) => list.splice(index + 1, 0, structuredClone(list[index])));
|
|
323
|
+
fitWidths(next.detail.columns);
|
|
324
|
+
return next;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// --- group operations -------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/** Move the group at index `at` to insertion point `to` — renesting. */
|
|
330
|
+
/** @type {(schema: any, at: number, to: number) => any} */
|
|
331
|
+
export let moveGroup = (schema, at, to) => {
|
|
332
|
+
let next = structuredClone(schema);
|
|
333
|
+
let index = at < to ? to - 1 : to;
|
|
334
|
+
next.groups.splice(index, 0, next.groups.splice(at, 1)[0]);
|
|
335
|
+
return next;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/** Items worth keeping — an absent or empty band splices nothing. */
|
|
339
|
+
/** @type {(band: any) => boolean} */
|
|
340
|
+
let carries = (band) => Array.isArray(band) && band.length > 0;
|
|
341
|
+
|
|
342
|
+
/** Lift a removed group's items into the enclosing bands: its header items
|
|
343
|
+
* append to that header, its footer items prepend to that footer. */
|
|
344
|
+
/** @type {(next: any, group: any, parent: string) => void} */
|
|
345
|
+
let splice = (next, group, parent) => {
|
|
346
|
+
if (carries(group.header)) bandArray(next, parent + "header").push(...group.header);
|
|
347
|
+
if (carries(group.footer)) bandArray(next, parent + "footer").unshift(...group.footer);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Remove the group at `index`. `"discard"` takes its bands with it;
|
|
352
|
+
* `"splice"` keeps their items — the header items append to the enclosing
|
|
353
|
+
* band's header (the next-outer group's, or the report's), the footer items
|
|
354
|
+
* prepend to the enclosing footer — so flattening a level loses nothing.
|
|
355
|
+
*/
|
|
356
|
+
/** @type {(schema: any, index: number, mode: "discard" | "splice") => any} */
|
|
357
|
+
export let removeGroup = (schema, index, mode) => {
|
|
358
|
+
let next = structuredClone(schema);
|
|
359
|
+
let [group] = next.groups.splice(index, 1);
|
|
360
|
+
// The enclosing band is the next-outer group's, or the report's own.
|
|
361
|
+
if (mode === "splice") splice(next, group, index > 0 ? "groups[" + (index - 1) + "]." : "");
|
|
362
|
+
return next;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/** Add the group's header or footer band, empty. A no-op when it exists. */
|
|
366
|
+
/** @type {(schema: any, index: number, which: "header" | "footer") => any} */
|
|
367
|
+
export let addGroupBand = (schema, index, which) => {
|
|
368
|
+
if (schema.groups[index][which] != null) return schema;
|
|
369
|
+
let next = structuredClone(schema);
|
|
370
|
+
next.groups[index][which] = [];
|
|
371
|
+
return next;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** Remove the group's header or footer band, contents and all — explicit. */
|
|
375
|
+
/** @type {(schema: any, index: number, which: "header" | "footer") => any} */
|
|
376
|
+
export let removeGroupBand = (schema, index, which) => {
|
|
377
|
+
let next = structuredClone(schema);
|
|
378
|
+
delete next.groups[index][which];
|
|
379
|
+
return next;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// --- what a path names -------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The kind of node a selectable path names, deciding which properties panel
|
|
386
|
+
* it gets: a band item (`text` / `image` by its type), a table `column`, a
|
|
387
|
+
* `total` cell, a `group`, or the `table` itself.
|
|
388
|
+
*/
|
|
389
|
+
// The path shapes that name a kind outright, tried in order.
|
|
390
|
+
let SHAPES = /** @type {[RegExp, string][]} */ ([
|
|
391
|
+
[/^groups\[\d+\]$/, "group"],
|
|
392
|
+
[/^detail\.columns\[\d+\]$/, "column"],
|
|
393
|
+
[/^detail\.total\[\d+\]$/, "total"],
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
/** @type {(path: string) => string | null} */
|
|
397
|
+
let shapeKind = (path) => {
|
|
398
|
+
for (let [shape, kind] of SHAPES) if (shape.test(path)) return kind;
|
|
399
|
+
return null;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** A band item, by the `type` it declares. */
|
|
403
|
+
/** @type {(schema: any, path: string) => string | null} */
|
|
404
|
+
let itemKind = (schema, path) => {
|
|
405
|
+
let node = resolve(schema, path);
|
|
406
|
+
if (!node || !/\[\d+\]$/.test(path)) return null;
|
|
407
|
+
return node.type === "image" ? "image" : "text";
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/** @type {(schema: any, path: string) => string | null} */
|
|
411
|
+
export let nodeKindAt = (schema, path) => {
|
|
412
|
+
if (path === "detail") return isTable(schema) ? "table" : null;
|
|
413
|
+
return shapeKind(path) ?? itemKind(schema, path);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/** The group a path sits inside, when the path is not that group itself. */
|
|
417
|
+
/** @type {(path: string) => string | null} */
|
|
418
|
+
let enclosingGroup = (path) => {
|
|
419
|
+
let group = path.match(/^(groups\[\d+\])/);
|
|
420
|
+
return group && group[1] !== path ? group[1] : null;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The selectable ancestors of a path, outermost first, self last — the
|
|
425
|
+
* breadcrumb, which is how a container the pointer cannot hit is reached.
|
|
426
|
+
*/
|
|
427
|
+
/** @type {(schema: any, path: string) => string[]} */
|
|
428
|
+
export let crumbsOf = (schema, path) => {
|
|
429
|
+
let out = [];
|
|
430
|
+
let group = enclosingGroup(path);
|
|
431
|
+
if (group) out.push(group);
|
|
432
|
+
if (path.startsWith("detail.") && isTable(schema)) out.push("detail");
|
|
433
|
+
out.push(path);
|
|
434
|
+
return out;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// --- the properties panel's field policy ------------------------------------
|
|
438
|
+
|
|
439
|
+
// Four kinds decide every control: literal-only, always a template, always an
|
|
440
|
+
// expression, and literal-or-expression — only the fourth gets the fx toggle,
|
|
441
|
+
// because only it has a literal form to swap back to. `name` and `type` are
|
|
442
|
+
// read-only (a group handle is read from arbitrary templates with no declared
|
|
443
|
+
// back-references; a type change to image needs an expression-only source).
|
|
444
|
+
let KINDS = /** @type {Record<string, string>} */ ({
|
|
445
|
+
value: "template",
|
|
446
|
+
header: "template",
|
|
447
|
+
alt: "template",
|
|
448
|
+
by: "expression",
|
|
449
|
+
source: "expression",
|
|
450
|
+
visible: "dual",
|
|
451
|
+
width: "literal",
|
|
452
|
+
fit: "literal",
|
|
453
|
+
take: "literal",
|
|
454
|
+
break: "literal",
|
|
455
|
+
reset: "literal",
|
|
456
|
+
columns: "literal",
|
|
457
|
+
name: "readonly",
|
|
458
|
+
type: "readonly",
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
/** @type {(key: string) => string} */
|
|
462
|
+
export let fieldKind = (key) => KINDS[key] ?? "dual";
|
|
463
|
+
|
|
464
|
+
/** The keys a node kind must keep: clearing one writes "" instead. A column
|
|
465
|
+
* requires its `header` as much as its `value` (SCHEMA.md, "Table detail"),
|
|
466
|
+
* and the shorthand string form is still that key. */
|
|
467
|
+
let REQUIRED = new Set(["value", "by", "source", "type", "name", "header"]);
|
|
468
|
+
|
|
469
|
+
/** @type {(node: any, key: string, value: any) => void} */
|
|
470
|
+
let writeKey = (node, key, value) => {
|
|
471
|
+
if (value === undefined || value === "") {
|
|
472
|
+
// Touch only the key you edited: an optional key clears away, a required
|
|
473
|
+
// one writes "" — a forced `color: ""` would be a definition error, but
|
|
474
|
+
// an absent `value` is one too, and "" keeps the fault visible where the
|
|
475
|
+
// author is looking.
|
|
476
|
+
if (REQUIRED.has(key)) node[key] = "";
|
|
477
|
+
else delete node[key];
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
node[key] = value;
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
/** Clear one style declaration, and the block itself once it holds nothing. */
|
|
484
|
+
/** @type {(node: any, name: string) => void} */
|
|
485
|
+
let clearStyle = (node, name) => {
|
|
486
|
+
if (!node.style) return;
|
|
487
|
+
delete node.style[name];
|
|
488
|
+
if (!Object.keys(node.style).length) delete node.style;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
/** Write one style declaration; an existing one updates in place, so
|
|
492
|
+
* declaration order survives, and a new one appends. */
|
|
493
|
+
/** @type {(node: any, name: string, value: any) => void} */
|
|
494
|
+
let writeStyle = (node, name, value) => {
|
|
495
|
+
if (value === undefined || value === "") return clearStyle(node, name);
|
|
496
|
+
node.style = node.style ?? {};
|
|
497
|
+
node.style[name] = value;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Write one field of the node at `path`. `key` may be a plain key or
|
|
502
|
+
* `style.<name>`; style declaration order is never rewritten — an existing
|
|
503
|
+
* declaration updates in place, a new one appends.
|
|
504
|
+
*/
|
|
505
|
+
/** @type {(schema: any, path: string, key: string, value: any) => any} */
|
|
506
|
+
export let writeField = (schema, path, key, value) => {
|
|
507
|
+
let next = structuredClone(schema);
|
|
508
|
+
let node = resolve(next, path);
|
|
509
|
+
let style = key.match(/^style\.(.+)$/);
|
|
510
|
+
// A column header widens for style and never narrows: `{ value: "Product" }`
|
|
511
|
+
// is a legal resting state, so a shorthand widens only when style arrives.
|
|
512
|
+
if (style) writeStyle(node, style[1], value);
|
|
513
|
+
else writeKey(node, key, value);
|
|
514
|
+
return next;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
/** Widen a shorthand string column header to its object form, for style. */
|
|
518
|
+
/** @type {(schema: any, columnPath: string) => any} */
|
|
519
|
+
export let widenHeader = (schema, columnPath) => {
|
|
520
|
+
let next = structuredClone(schema);
|
|
521
|
+
let column = resolve(next, columnPath);
|
|
522
|
+
if (typeof column.header === "string") column.header = { value: column.header };
|
|
523
|
+
return next;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// --- placeholders: what the render leaves out --------------------------------
|
|
527
|
+
|
|
528
|
+
/** What a hidden item's placeholder shows: the template the author wrote. */
|
|
529
|
+
/** @type {(item: any) => string} */
|
|
530
|
+
let sourceOf = (item) => (typeof item?.value === "string" ? item.value : "");
|
|
531
|
+
|
|
532
|
+
/** One band's placeholders: its own when it is empty, else its hidden items. */
|
|
533
|
+
/** @type {(band: any, items: any[], rendered: Set<string>, out: any[]) => void} */
|
|
534
|
+
let bandPlaceholders = (band, items, rendered, out) => {
|
|
535
|
+
if (!items.length) return void out.push({ kind: "band", path: band.path });
|
|
536
|
+
items.forEach((/** @type {any} */ item, /** @type {number} */ i) => {
|
|
537
|
+
let path = band.path + "[" + i + "]";
|
|
538
|
+
if (!rendered.has(path)) out.push({ kind: "item", path, source: sourceOf(item) });
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Given the schema and the set of paths that produced output (every
|
|
544
|
+
* `data-q-path` in the fragment), the placeholders in document order. Two
|
|
545
|
+
* meanings, one affordance: an empty band's placeholder is an insertion point
|
|
546
|
+
* and drop target, never a selection (a band has no keys); a hidden item's is
|
|
547
|
+
* a real selectable node carrying its template source, so several hidden
|
|
548
|
+
* items are distinguishable. Only v0.1's editable surface: `detail.row`,
|
|
549
|
+
* `empty` and the page bands are unaffected, and an absent band is not in the
|
|
550
|
+
* document, so it gets nothing.
|
|
551
|
+
*
|
|
552
|
+
* @type {(schema: any, rendered: Set<string>) => (
|
|
553
|
+
* { kind: "band", path: string } |
|
|
554
|
+
* { kind: "item", path: string, source: string })[]}
|
|
555
|
+
*/
|
|
556
|
+
export let placeholders = (schema, rendered) => {
|
|
557
|
+
/** @type {ReturnType<typeof placeholders>} */
|
|
558
|
+
let out = [];
|
|
559
|
+
for (let band of bands(schema)) {
|
|
560
|
+
let items = resolve(schema, band.path);
|
|
561
|
+
if (!band.table && Array.isArray(items)) bandPlaceholders(band, items, rendered, out);
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// --- host mistakes -----------------------------------------------------------
|
|
567
|
+
//
|
|
568
|
+
// The check messages, folded into the pure surface rather than kept as a
|
|
569
|
+
// separate check.js the way the viewer's is. Same discipline: every failure
|
|
570
|
+
// names the property, and an invalid property keeps the last good values.
|
|
571
|
+
|
|
572
|
+
/** @type {(message: string) => never} */
|
|
573
|
+
let refuse = (message) => {
|
|
574
|
+
throw new TypeError("quario-editor: " + message);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/** @type {(value: any) => boolean} */
|
|
578
|
+
let isObject = (value) => !!value && typeof value === "object";
|
|
579
|
+
/** @type {(value: any) => boolean} */
|
|
580
|
+
let record = (value) => isObject(value) && !Array.isArray(value);
|
|
581
|
+
|
|
582
|
+
/** @type {(value: any) => any} */
|
|
583
|
+
export let checkSchema = (value) => {
|
|
584
|
+
if (value !== undefined && !record(value)) refuse("schema: expected a report document object");
|
|
585
|
+
return value;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
/** @type {(value: any) => any} */
|
|
589
|
+
export let checkInstance = (value) => {
|
|
590
|
+
if (value !== undefined && !(record(value) && typeof value.plan === "function"))
|
|
591
|
+
refuse("instance: expected a quario instance (its plan() is the editor's loop)");
|
|
592
|
+
return value;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// Named, not duck-typed: a pdf target would compile happily and resolve bytes
|
|
596
|
+
// the editor would then try to put in the DOM, failing far from the mistake —
|
|
597
|
+
// so the wrong target is named here, where the mistake is.
|
|
598
|
+
/** @type {(value: any) => void} */
|
|
599
|
+
let checkHtml = (value) => {
|
|
600
|
+
if (!record(value) || typeof value.compile !== "function")
|
|
601
|
+
refuse("target: expected the html target from @quario/html");
|
|
602
|
+
if (value.name !== "html") refuse('target: expected the "html" target, got "' + value.name + '"');
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
/** @type {(value: any) => any} */
|
|
606
|
+
export let checkTarget = (value) => {
|
|
607
|
+
if (value !== undefined) checkHtml(value);
|
|
608
|
+
return value;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/** @type {(value: any) => any} */
|
|
612
|
+
export let checkFunctions = (value) => {
|
|
613
|
+
if (value !== undefined && !record(value)) refuse("functions: expected a record of functions");
|
|
614
|
+
return value;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// The same sheet vocabulary the viewer's `page` takes (the pdf target's page
|
|
618
|
+
// sizes), purely visual: sheet width and padding. Restated rather than
|
|
619
|
+
// imported, because the editor depends on no sibling package.
|
|
620
|
+
let SIZES = /** @type {Record<string, [number, number]>} */ ({
|
|
621
|
+
A4: [595.28, 841.89],
|
|
622
|
+
letter: [612, 792],
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
/** The default margin, in points (0.75in) — the viewer's, restated. */
|
|
626
|
+
let MARGIN = 54;
|
|
627
|
+
|
|
628
|
+
/** A `[width, height]` pair the host wrote itself. */
|
|
629
|
+
/** @type {(size: any) => boolean} */
|
|
630
|
+
let isPair = (size) => Array.isArray(size) && size.length === 2 && size.every(Number.isFinite);
|
|
631
|
+
/** One of the named sizes, from the closed set. */
|
|
632
|
+
/** @type {(size: any) => boolean} */
|
|
633
|
+
let isNamed = (size) => typeof size === "string" && Object.hasOwn(SIZES, size);
|
|
634
|
+
|
|
635
|
+
/** @type {(size?: any) => [number, number] | null} */
|
|
636
|
+
let sizeOf = (size = "A4") => {
|
|
637
|
+
if (isPair(size)) return /** @type {[number, number]} */ ([...size]);
|
|
638
|
+
return isNamed(size) ? SIZES[size] : null;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
/** A margin has to leave the sheet room on both sides of its shorter edge. */
|
|
642
|
+
/** @type {(margin: any, box: [number, number]) => boolean} */
|
|
643
|
+
let fitsSheet = (margin, box) =>
|
|
644
|
+
Number.isFinite(margin) && margin >= 0 && margin * 2 < Math.min(box[0], box[1]);
|
|
645
|
+
|
|
646
|
+
/** @type {(margin: any, box: [number, number]) => number} */
|
|
647
|
+
let marginOf = (margin, box) => {
|
|
648
|
+
let used = margin === undefined ? MARGIN : margin;
|
|
649
|
+
if (!fitsSheet(used, box)) refuse("page.margin: expected a margin leaving room on the sheet");
|
|
650
|
+
return used;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
/** @type {(value: any) => { width: number, height: number, margin: number }} */
|
|
654
|
+
export let checkPage = (value) => {
|
|
655
|
+
if (value === undefined) return { width: SIZES.A4[0], height: SIZES.A4[1], margin: MARGIN };
|
|
656
|
+
if (!isObject(value)) refuse("page: expected a page geometry object");
|
|
657
|
+
let box = sizeOf(value.size);
|
|
658
|
+
if (!box) refuse('page.size: expected "A4", "letter", or [width, height] points');
|
|
659
|
+
return { width: box[0], height: box[1], margin: marginOf(value.margin, box) };
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
let SCHEME = /** @type {Record<string, string>} */ ({
|
|
663
|
+
light: "light",
|
|
664
|
+
dark: "dark",
|
|
665
|
+
auto: "light dark",
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
/** @type {(value: any) => string} */
|
|
669
|
+
export let checkScheme = (value) => {
|
|
670
|
+
if (value === undefined) return "light";
|
|
671
|
+
if (typeof value !== "string" || !Object.hasOwn(SCHEME, value))
|
|
672
|
+
refuse('colorScheme: expected "light", "dark", or "auto"');
|
|
673
|
+
return SCHEME[value];
|
|
674
|
+
};
|