@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/lib/rail.js ADDED
@@ -0,0 +1,506 @@
1
+ /**
2
+ * The right rail's three sections — Properties, Groups, Problems — as Lit
3
+ * templates over the document model. Two flat groups per node, Content and
4
+ * Style, no progressive disclosure: at most six own keys plus nine style
5
+ * names is not a surface that earns one. Only literal-or-expression fields
6
+ * get the fx toggle; a group's `by` and an image's `source` are bare text
7
+ * fields, because there is no literal form of them to offer.
8
+ *
9
+ * Commits are the element's: every control reports through `act.edit`, which
10
+ * is three verbs. `touch` moves the working document without taking a history
11
+ * entry, so the preview and the problem list answer to the keystroke.
12
+ * `settle` is a typed control's own gesture ending — blurred, Entered, or a
13
+ * picker released — which is what the `touch`es before it were building
14
+ * toward. `write` is a discrete gesture in one act: a checkbox, an alignment
15
+ * button, an fx toggle. The distinction between the last two is the whole
16
+ * reason this module has three verbs rather than two: an fx toggle carries
17
+ * the same `key` as the field beside it, so routing by key alone would let it
18
+ * swallow the typing it interrupted instead of letting that land first.
19
+ */
20
+ import { html, nothing } from "lit";
21
+ import { fieldKind, nodeKindAt, resolve } from "./document.js";
22
+
23
+ /**
24
+ * The rail's whole seam to the element: how a control says what it did.
25
+ *
26
+ * @typedef {{
27
+ * touch: (key: string, value: any) => void,
28
+ * settle: (key: string, value: any) => void,
29
+ * write: (key: string, value: any) => void,
30
+ * }} Edit
31
+ */
32
+
33
+ /** The closed style vocabulary an item offers, in one fixed panel order. */
34
+ let STYLES = [
35
+ "family",
36
+ "size",
37
+ "bold",
38
+ "italic",
39
+ "underline",
40
+ "strikethrough",
41
+ "color",
42
+ "background",
43
+ "align",
44
+ ];
45
+ // The vocabulary that differs from the full set: an image has two style
46
+ // declarations, and a group or the table itself has none.
47
+ /** @type {Record<string, string[]>} */
48
+ let STYLE_VOCABULARY = { image: ["align", "background"], group: [], table: [] };
49
+
50
+ let FAMILIES = ["", "sans", "serif", "mono"];
51
+ let ALIGNS = ["left", "center", "right"];
52
+ let FLAGS = new Set(["bold", "italic", "underline", "strikethrough"]);
53
+
54
+ /** @type {(value: any) => boolean} */
55
+ let isExpr = (value) => typeof value === "string" && value.startsWith("=");
56
+
57
+ /** What a plain text field writes: the string, as typed. */
58
+ /** @type {(value: string) => any} */
59
+ let asIs = (value) => value;
60
+
61
+ /** Which of a node's keys a problem belongs to: "" for the node itself,
62
+ * `style.color` for one inside it, null for a problem elsewhere. */
63
+ /** @type {(at: string, path: string) => string | null} */
64
+ let subKey = (at, path) => {
65
+ if (at === path) return "";
66
+ return at.startsWith(path + ".") ? at.slice(path.length + 1) : null;
67
+ };
68
+
69
+ /**
70
+ * The problems that sit at or under one node, mapped to the sub-key they
71
+ * belong to, with the exact character span when the diagnostic carries one.
72
+ *
73
+ * @type {(problems: readonly any[], path: string) => Map<string, any>}
74
+ */
75
+ let inlineProblems = (problems, path) => {
76
+ let out = new Map();
77
+ for (let problem of problems) {
78
+ let key = subKey(problem.path, path);
79
+ if (key !== null && !out.has(key)) out.set(key, problem);
80
+ }
81
+ return out;
82
+ };
83
+
84
+ /** Whether a diagnostic carries offsets at all. */
85
+ /** @type {(d: any) => boolean} */
86
+ let hasSpan = (d) => !!d && d.start != null && d.end != null;
87
+
88
+ /** Whether those offsets land inside the source the panel is showing. */
89
+ /** @type {(problem: any) => boolean} */
90
+ let spanned = (problem) =>
91
+ hasSpan(problem.diagnostic) &&
92
+ problem.source != null &&
93
+ problem.diagnostic.end <= problem.source.length;
94
+
95
+ /** The message without its located prefix: the field it sits under already
96
+ * names the node, so repeating the path there is noise. Built from the
97
+ * problem's own `path` and `source` — the engine kept both as data precisely
98
+ * so no consumer has to parse a wording format back apart. */
99
+ /** @type {(problem: any) => string} */
100
+ let trimmed = ({ path, source, message }) => {
101
+ let prefix = (source === undefined ? path : path + " [" + source + "]") + ": ";
102
+ return message.startsWith(prefix) ? message.slice(prefix.length) : message;
103
+ };
104
+
105
+ /** The message with its located span underlined, when the engine knows it. */
106
+ /** @type {(problem: any) => unknown} */
107
+ let said = (problem) => {
108
+ if (!spanned(problem)) return html`<p class="qe-inline-error">${problem.message}</p>`;
109
+ let { diagnostic: d, source: src } = problem;
110
+ let end = Math.max(d.end, d.start + 1);
111
+ return html`<p class="qe-inline-error">
112
+ ${trimmed(problem)}<br />
113
+ <code>${src.slice(0, d.start)}<u>${src.slice(d.start, end)}</u>${src.slice(end)}</code>
114
+ </p>`;
115
+ };
116
+
117
+ /** @type {(faults: Map<string, any>, key: string) => unknown} */
118
+ let fault = (faults, key) => {
119
+ let problem = faults.get(key);
120
+ return problem ? said(problem) : nothing;
121
+ };
122
+
123
+ /** A literal-or-expression style flag (`bold`, `italic`, ...) edits as a
124
+ * checkbox; everything else is a text field. */
125
+ /** @type {(kind: string, key: string) => boolean} */
126
+ let asFlag = (kind, key) => kind === "dual" && FLAGS.has(key.replace(/^style\./, ""));
127
+
128
+ // The closed sets a literal field offers, so an author picks rather than
129
+ // spells. `visible`'s literal form is a boolean — SCHEMA.md makes any other
130
+ // literal a definition error — which is why it is a checkbox until the fx
131
+ // toggle turns it into an expression.
132
+ /** @type {Record<string, string[]>} */
133
+ let CHOICES = { fit: ["natural", "width"], break: ["page"], reset: ["page"] };
134
+ /** The literal fields whose value is a number, never the string typed. */
135
+ let COUNTED = new Set(["width", "take", "columns"]);
136
+
137
+ /** A value already written as an expression edits as its source, whatever
138
+ * control its literal form would have had. */
139
+ /** @type {(kind: string, key: string, ctx: any) => unknown} */
140
+ let literalOrFlag = (kind, key, ctx) => {
141
+ if (key === "visible") return visibleControl(ctx);
142
+ return asFlag(kind, key) ? flagControl(ctx) : textControl(ctx);
143
+ };
144
+
145
+ /** @type {(ctx: any) => unknown} */
146
+ let readonlyControl = ({ value }) => html`<input type="text" .value=${value ?? ""} disabled />`;
147
+
148
+ // Four kinds decide every control (quario-yre.7): literal-only, always a
149
+ // template, always an expression, and literal-or-expression. Only the fourth
150
+ // can already hold an expression, and then it edits as its source.
151
+ /** @type {Record<string, (key: string, value: any, ctx: any) => unknown>} */
152
+ let CONTROLS = {
153
+ readonly: (_key, _value, ctx) => readonlyControl(ctx),
154
+ literal: (key, _value, ctx) => literalControl(key, ctx),
155
+ template: (_key, _value, ctx) => textControl(ctx),
156
+ expression: (_key, _value, ctx) => textControl(ctx),
157
+ dual: (key, value, ctx) => (isExpr(value) ? textControl(ctx) : literalOrFlag("dual", key, ctx)),
158
+ };
159
+
160
+ /** @type {(kind: string, key: string, value: any, ctx: any) => unknown} */
161
+ let fieldControl = (kind, key, value, ctx) => CONTROLS[kind](key, value, ctx);
162
+
163
+ /** The two handlers a typed control needs: `input` moves the working
164
+ * document, `change` ends the gesture. `read` is what the schema wants out of
165
+ * the DOM's string. */
166
+ /** @type {(edit: Edit, key: string, read: (value: string) => any) => { live: any, commit: any }} */
167
+ let handlers = (edit, key, read) => ({
168
+ live: (/** @type {any} */ event) => edit.touch(key, read(event.target.value)),
169
+ commit: (/** @type {any} */ event) => edit.settle(key, read(event.target.value)),
170
+ });
171
+
172
+ /** A count writes a number: `width: "30"` is a definition error, and the
173
+ * author never sees the difference between the two on screen. */
174
+ /** @type {(value: string) => any} */
175
+ let asCount = (value) => (value === "" ? "" : Number(value));
176
+
177
+ /** A number field's intermediate states — a lone `-`, a half-typed `1e` —
178
+ * reach the DOM as an empty value. Live-writing that would clear the key, and
179
+ * the re-render would then erase what was typed, so a count moves the working
180
+ * document only once it is one. Clearing the field still commits on blur. */
181
+ /** @type {(edit: Edit, key: string) => any} */
182
+ let liveCount = (edit, key) => (/** @type {any} */ event) => {
183
+ if (event.target.value === "") return;
184
+ edit.touch(key, Number(event.target.value));
185
+ };
186
+
187
+ /** The counted control both `width`-style literals and `style.size` use: the
188
+ * ordinary pair read as a count, with only the live half replaced. */
189
+ /** @type {(ctx: any, least: unknown) => unknown} */
190
+ let countedControl = ({ edit, key, value }, least) => html`<input
191
+ type="number"
192
+ min=${least ?? nothing}
193
+ .value=${value ?? ""}
194
+ @input=${liveCount(edit, key)}
195
+ @change=${handlers(edit, key, asCount).commit}
196
+ />`;
197
+
198
+ /** @type {(key: string, ctx: any) => unknown} */
199
+ let literalControl = (key, ctx) => {
200
+ if (COUNTED.has(key)) return countedControl(ctx, undefined);
201
+ return CHOICES[key] ? choiceControl(CHOICES[key], ctx) : textControl(ctx);
202
+ };
203
+
204
+ /**
205
+ * One field. `kind` narrows the control; `dual` fields carry the fx toggle
206
+ * that swaps the control for its expression source.
207
+ */
208
+ /** @type {(label: string, key: string, value: any, kind: string, faults: Map<string, any>, edit: Edit) => unknown} */
209
+ let field = (label, key, value, kind, faults, edit) => {
210
+ let ctx = { key, value, edit, ...handlers(edit, key, asIs) };
211
+ let control = fieldControl(kind, key, value, ctx);
212
+ return html`<label class="qe-field">
213
+ <span>${label}</span>
214
+ <span class="qe-row">${control}${kind === "dual" ? fxButton(key, value, edit) : nothing}</span>
215
+ ${fault(faults, key)}
216
+ </label>`;
217
+ };
218
+
219
+ // The fx toggle: only a literal-or-expression field has a literal form to
220
+ // swap back to, which is why a group's `by` and an image's `source` never
221
+ // carry one.
222
+ /** @type {(key: string, value: any, edit: Edit) => unknown} */
223
+ let fxButton = (key, value, edit) => html`<button
224
+ class="qe-button"
225
+ title="Switch between a value and an expression"
226
+ aria-pressed=${isExpr(value) ? "true" : "false"}
227
+ @click=${() => edit.write(key, isExpr(value) ? "" : "=")}
228
+ >
229
+ fx
230
+ </button>`;
231
+
232
+ /** @type {(ctx: any) => unknown} */
233
+ let flagControl = ({ value, key, edit }) => html`<input
234
+ type="checkbox"
235
+ .checked=${!!value}
236
+ @change=${(/** @type {any} */ event) => edit.write(key, event.target.checked ? true : "")}
237
+ />`;
238
+
239
+ // `visible` is the mirror image of a style flag: an absent key means visible,
240
+ // so the box is checked by default and unchecking it writes the literal
241
+ // `false` the schema wants — clearing the key instead would make the item
242
+ // visible again, which is the one control that could turn it back on.
243
+ /** @type {(ctx: any) => unknown} */
244
+ let visibleControl = ({ value, key, edit }) => html`<input
245
+ type="checkbox"
246
+ .checked=${value !== false}
247
+ @change=${(/** @type {any} */ event) => edit.write(key, event.target.checked ? "" : false)}
248
+ />`;
249
+
250
+ /** @type {(ctx: any) => unknown} */
251
+ let textControl = ({ value, live, commit }) =>
252
+ html`<input type="text" .value=${value ?? ""} @input=${live} @change=${commit} />`;
253
+
254
+ /** One of a closed set, plus the empty choice that clears the key. */
255
+ /** @type {(options: string[], ctx: any) => unknown} */
256
+ let choiceControl = (options, { value, commit }) => html`<select @change=${commit}>
257
+ ${["", ...options].map(
258
+ (option) =>
259
+ html`<option value=${option} ?selected=${(value ?? "") === option}>
260
+ ${option || "(none)"}
261
+ </option>`,
262
+ )}
263
+ </select>`;
264
+
265
+ /** @type {(ctx: any) => unknown} */
266
+ let colorControl = ({ value, key, edit, live, commit }) => html`<span class="qe-row">
267
+ <input
268
+ type="color"
269
+ .value=${typeof value === "string" && value.startsWith("#") ? value : "#000000"}
270
+ @input=${live}
271
+ @change=${commit}
272
+ />
273
+ <button class="qe-button" title="Clear" @click=${() => edit.write(key, "")}>×</button>
274
+ </span>`;
275
+
276
+ // Real controls, one per style name — a colour picker, a bold toggle,
277
+ // alignment buttons — so nothing is a value the author has to remember.
278
+ // Anything unlisted falls back to a plain text field.
279
+ /** @type {Record<string, (ctx: any) => unknown>} */
280
+ let STYLE_CONTROLS = {
281
+ family: ({ value, commit }) => html`<select @change=${commit}>
282
+ ${FAMILIES.map(
283
+ (option) =>
284
+ html`<option value=${option} ?selected=${(value ?? "") === option}>
285
+ ${option || "(default)"}
286
+ </option>`,
287
+ )}
288
+ </select>`,
289
+ align: ({ value, key, edit }) => html`<span class="qe-row" role="group" aria-label="align">
290
+ ${ALIGNS.map(
291
+ (option) => html`<button
292
+ class="qe-button"
293
+ aria-pressed=${value === option ? "true" : "false"}
294
+ @click=${() => edit.write(key, value === option ? "" : option)}
295
+ >
296
+ ${option}
297
+ </button>`,
298
+ )}
299
+ </span>`,
300
+ size: (ctx) => countedControl(ctx, "1"),
301
+ color: colorControl,
302
+ background: colorControl,
303
+ };
304
+
305
+ /** @type {(ctx: any) => unknown} */
306
+ let styleControl = (ctx) => {
307
+ if (FLAGS.has(ctx.name)) return flagControl(ctx);
308
+ return (STYLE_CONTROLS[ctx.name] ?? textControl)(ctx);
309
+ };
310
+
311
+ /** @type {(name: string, node: any, faults: Map<string, any>, edit: Edit) => unknown} */
312
+ let styleField = (name, node, faults, edit) => {
313
+ let value = node?.style?.[name];
314
+ let key = "style." + name;
315
+ // An expression-valued style edits as its source, through the same field a
316
+ // `visible` expression gets.
317
+ if (isExpr(value)) return field(name, key, value, "dual", faults, edit);
318
+ let ctx = { name, key, value, edit, ...handlers(edit, key, asIs) };
319
+ return html`<label class="qe-field">
320
+ <span>${name}</span>
321
+ <span class="qe-row">${styleControl(ctx)}${fxButton(key, value, edit)}</span>
322
+ ${fault(faults, key)}
323
+ </label>`;
324
+ };
325
+
326
+ /** Content fields per node kind — each an entry of [label, key, kind]. */
327
+ let CONTENT = /** @type {Record<string, [string, string][]>} */ ({
328
+ text: [
329
+ ["value", "value"],
330
+ ["visible", "visible"],
331
+ ],
332
+ image: [
333
+ ["source", "source"],
334
+ ["alt", "alt"],
335
+ ["fit", "fit"],
336
+ ["visible", "visible"],
337
+ ],
338
+ column: [
339
+ ["header", "header"],
340
+ ["value", "value"],
341
+ ["visible", "visible"],
342
+ ["width", "width"],
343
+ ],
344
+ total: [
345
+ ["value", "value"],
346
+ ["visible", "visible"],
347
+ ],
348
+ group: [
349
+ ["name", "name"],
350
+ ["by", "by"],
351
+ ["break", "break"],
352
+ ["reset", "reset"],
353
+ ["take", "take"],
354
+ ["columns", "columns"],
355
+ ],
356
+ table: [],
357
+ });
358
+
359
+ /** Every rail section is a titled block; the title is the only chrome. */
360
+ /** @type {(title: string, body: unknown) => unknown} */
361
+ let section = (title, body) => html`<div class="qe-section">
362
+ <h3>${title}</h3>
363
+ ${body}
364
+ </div>`;
365
+
366
+ /** A column's shorthand string header edits as its own template; the widened
367
+ * object form edits through `.value`. Widening is the element's write path. */
368
+ /** @type {(header: any) => any} */
369
+ let headerText = (header) => (typeof header === "string" ? header : header?.value);
370
+
371
+ /** @type {(node: any, key: string) => any} */
372
+ let readKey = (node, key) => (key === "header" ? headerText(node?.header) : node?.[key]);
373
+
374
+ /** The style vocabulary a node kind actually has: an image shows its two
375
+ * declarations rather than nine controls with seven disabled. */
376
+ /** @type {(kind: string) => string[]} */
377
+ let stylesFor = (kind) => STYLE_VOCABULARY[kind] ?? STYLES;
378
+
379
+ /** @type {(kind: string, node: any, faults: Map<string, any>, edit: Edit) => unknown} */
380
+ let styleFields = (kind, node, faults, edit) => {
381
+ let names = stylesFor(kind);
382
+ if (!names.length) return nothing;
383
+ return html`<h3>Style</h3>
384
+ ${names.map((name) => styleField(name, node, faults, edit))}`;
385
+ };
386
+
387
+ /** The table verbs, on the table and on any column of it. */
388
+ /** @type {(kind: string, act: any) => unknown} */
389
+ let columnVerbs = (kind, act) => {
390
+ if (kind !== "column" && kind !== "table") return nothing;
391
+ return html`<div class="qe-row">
392
+ <button class="qe-button" @click=${act.insertColumn}>Add column</button>
393
+ ${
394
+ kind === "column"
395
+ ? html`<button class="qe-button" @click=${act.duplicateColumn}>Duplicate</button>
396
+ <button class="qe-button" @click=${act.removeColumn}>Remove</button>`
397
+ : nothing
398
+ }
399
+ </div>`;
400
+ };
401
+
402
+ /**
403
+ * The Properties section for the selected node, or its empty prompting form.
404
+ *
405
+ * @param {any} schema
406
+ * @param {string | null} selected
407
+ * @param {readonly any[]} problems
408
+ * @param {{ edit: Edit,
409
+ * insertColumn: () => void, removeColumn: () => void, duplicateColumn: () => void }} act
410
+ */
411
+ export let propertiesSection = (schema, selected, problems, act) => {
412
+ let kind = selected && nodeKindAt(schema, selected);
413
+ if (!selected || !kind)
414
+ return section("Properties", html`<p>Select something in the preview.</p>`);
415
+ let node = resolve(schema, selected);
416
+ let faults = inlineProblems(problems, selected);
417
+ return section(
418
+ "Properties",
419
+ html`<p><code class="qe-crumb">${selected}</code></p>
420
+ ${columnVerbs(kind, act)}
421
+ ${CONTENT[kind].map(([label, key]) =>
422
+ field(label, key, readKey(node, key), fieldKind(key), faults, act.edit),
423
+ )}
424
+ ${styleFields(kind, node, faults, act.edit)}`,
425
+ );
426
+ };
427
+
428
+ /** One toolbar button in a group's row. */
429
+ /** @type {(title: string, glyph: string, click: () => void, opts?: any) => unknown} */
430
+ let rowButton = (title, glyph, click, opts = {}) => html`<button
431
+ class="qe-button"
432
+ title=${title}
433
+ ?disabled=${!!opts.disabled}
434
+ aria-pressed=${opts.pressed ?? nothing}
435
+ @click=${click}
436
+ >
437
+ ${glyph}
438
+ </button>`;
439
+
440
+ /** @type {Record<string, string>} */
441
+ let GLYPH = { header: "H", footer: "F" };
442
+
443
+ /** A group's header or footer band: one verb that adds it, one that removes
444
+ * it whole. There is no such thing as a hidden band, so the editor presents
445
+ * none — an off-then-on toggle would discard the band's contents. */
446
+ /** @type {(group: any, i: number, which: "header" | "footer", act: any) => unknown} */
447
+ let bandButton = (group, i, which, act) => {
448
+ let band = group?.[which];
449
+ let title = (band ? "Remove the " : "Add a ") + which + " band";
450
+ let pressed = band ? "true" : "false";
451
+ return rowButton(title, GLYPH[which], () => act.band(i, which, band == null), { pressed });
452
+ };
453
+
454
+ /** @type {(group: any, i: number, count: number, act: any) => unknown} */
455
+ let groupRow = (group, i, count, act) => html`<div class="qe-group-row">
456
+ <button class="qe-button qe-name" @click=${() => act.select("groups[" + i + "]")}>
457
+ ${group?.name}
458
+ </button>
459
+ ${rowButton("Move up", "↑", () => act.move(i, i - 1), { disabled: i === 0 })}
460
+ ${rowButton("Move down", "↓", () => act.move(i, i + 2), { disabled: i === count - 1 })}
461
+ ${bandButton(group, i, "header", act)} ${bandButton(group, i, "footer", act)}
462
+ ${rowButton("Remove the group, keeping its items", "⇱", () => act.remove(i, "splice"))}
463
+ ${rowButton("Remove the group and its bands", "×", () => act.remove(i, "discard"))}
464
+ </div>`;
465
+
466
+ /**
467
+ * The Groups section: the docked list no single part of the page shows. A
468
+ * group spans its whole instance, so its controls cannot sit on any one part
469
+ * of the page — this is their one home.
470
+ *
471
+ * @param {any} schema
472
+ * @param {{ select: (path: string) => void, move: (at: number, to: number) => void,
473
+ * remove: (at: number, mode: "discard" | "splice") => void,
474
+ * band: (at: number, which: "header" | "footer", add: boolean) => void }} act
475
+ */
476
+ export let groupsSection = (schema, act) => {
477
+ let groups = Array.isArray(schema?.groups) ? schema.groups : [];
478
+ return section(
479
+ "Groups",
480
+ html`${groups.length ? nothing : html`<p>No groups.</p>`}
481
+ ${groups.map((/** @type {any} */ group, /** @type {number} */ i) =>
482
+ groupRow(group, i, groups.length, act),
483
+ )}`,
484
+ );
485
+ };
486
+
487
+ /**
488
+ * The Problems section: every problem in the document; clicking one selects
489
+ * the node it belongs to.
490
+ *
491
+ * @param {readonly any[]} problems
492
+ * @param {{ select: (path: string) => void }} act
493
+ */
494
+ export let problemsSection = (problems, act) =>
495
+ section(
496
+ "Problems",
497
+ html`${problems.length ? nothing : html`<p>None.</p>`}
498
+ ${problems.map(
499
+ (/** @type {any} */ problem) => html`<button
500
+ class="qe-problem"
501
+ @click=${() => act.select(problem.path)}
502
+ >
503
+ ${problem.message}
504
+ </button>`,
505
+ )}`,
506
+ );
@@ -0,0 +1,9 @@
1
+ import { QuarioEditor } from "./index.js";
2
+
3
+ declare global {
4
+ interface HTMLElementTagNameMap {
5
+ "quario-editor": QuarioEditor;
6
+ }
7
+ }
8
+
9
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The one-line define, as its own entry so the main module stays
3
+ * side-effect-free: importing `@quario/editor` gives you the class and
4
+ * defines nothing, importing `@quario/editor/register` gives the tag to
5
+ * hosts that want the platform default. A host that wants its own tag calls
6
+ * `customElements.define` itself (docs/adr/0005-the-surfaces-are-custom-elements.md).
7
+ */
8
+ import { QuarioEditor, TAG } from "./index.js";
9
+
10
+ customElements.define(TAG, QuarioEditor);