@openpresentation/opf-editor 0.27.0 → 0.28.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/README.md +4 -4
- package/dist/design-controls.js +3 -2
- package/dist/design-options.js +1 -1
- package/dist/json-editor.d.ts +33 -0
- package/dist/json-editor.js +83 -2
- package/dist/layout-adapter.js +96 -0
- package/dist/layout-canvas.js +122 -232
- package/dist/layout-editor-ui.js +46 -185
- package/dist/layout-inspector.js +36 -99
- package/dist/layout-layers.js +75 -92
- package/dist/layout-model.js +62 -103
- package/dist/react.d.ts +4 -0
- package/dist/react.js +9 -2
- package/dist/source-color-scheme.js +60 -0
- package/dist/source-pane.d.ts +85 -0
- package/dist/source-pane.js +151 -9
- package/dist/studio-kit/commands.d.ts +38 -0
- package/dist/studio-kit/commands.js +148 -0
- package/dist/studio-kit/dom.d.ts +36 -0
- package/dist/studio-kit/dom.js +162 -0
- package/dist/studio-kit/drag.d.ts +26 -0
- package/dist/studio-kit/drag.js +127 -0
- package/dist/studio-kit/gesture.d.ts +29 -0
- package/dist/studio-kit/gesture.js +87 -0
- package/dist/studio-kit/index.d.ts +29 -0
- package/dist/studio-kit/index.js +12 -0
- package/dist/studio-kit/inspector.d.ts +22 -0
- package/dist/studio-kit/inspector.js +119 -0
- package/dist/studio-kit/overlay.d.ts +29 -0
- package/dist/studio-kit/overlay.js +182 -0
- package/dist/studio-kit/selection.d.ts +30 -0
- package/dist/studio-kit/selection.js +106 -0
- package/dist/studio-kit/tree.d.ts +9 -0
- package/dist/studio-kit/tree.js +82 -0
- package/dist/studio-kit/viewport.d.ts +29 -0
- package/dist/studio-kit/viewport.js +153 -0
- package/dist/styles.css +28 -7
- package/dist/workspace.d.ts +26 -1
- package/dist/workspace.js +13 -0
- package/package.json +10 -10
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// CE-18, the studio kit: DOM helpers every studio part shares. `createDom` binds element, icon and button factories to one document and
|
|
2
|
+
// one class prefix (Layout Studio passes "opf-lx", so its classes are unchanged), the inline Lucide icons, `openMenu` (the menu behind every
|
|
3
|
+
// context menu), the typing-target guard of the shortcuts, and small formatting helpers. Plain DOM; no network; nothing about a document
|
|
4
|
+
// format.
|
|
5
|
+
|
|
6
|
+
// Lucide icons (ISC license), as path data.
|
|
7
|
+
export const ICONS = {
|
|
8
|
+
MousePointer2: '<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/>',
|
|
9
|
+
Columns2: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/>',
|
|
10
|
+
Rows2: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/>',
|
|
11
|
+
Plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
|
12
|
+
Heading: '<path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/>',
|
|
13
|
+
Hand: '<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>',
|
|
14
|
+
Eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
|
|
15
|
+
Undo2: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/>',
|
|
16
|
+
Redo2: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13"/>',
|
|
17
|
+
ChevronRight: '<path d="m9 18 6-6-6-6"/>',
|
|
18
|
+
ChevronDown: '<path d="m6 9 6 6 6-6"/>',
|
|
19
|
+
X: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
20
|
+
Check: '<path d="M20 6 9 17l-5-5"/>',
|
|
21
|
+
CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
|
|
22
|
+
Info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
23
|
+
Trash2: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
|
|
24
|
+
Copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
|
25
|
+
GripVertical: '<circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/>',
|
|
26
|
+
ZoomIn: '<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/>',
|
|
27
|
+
ZoomOut: '<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="8" x2="14" y1="11" y2="11"/>',
|
|
28
|
+
Maximize: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
|
|
29
|
+
Square: '<rect width="18" height="18" x="3" y="3" rx="2"/>',
|
|
30
|
+
Layers: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
|
|
31
|
+
Pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
|
|
32
|
+
Unfold: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
|
33
|
+
BetweenHorizontalStart: '<rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/>',
|
|
34
|
+
AlignLeft: '<path d="M15 12H3"/><path d="M17 18H3"/><path d="M21 6H3"/>',
|
|
35
|
+
List: '<path d="M3 12h.01"/><path d="M3 18h.01"/><path d="M3 6h.01"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M8 6h13"/>',
|
|
36
|
+
Image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
|
37
|
+
Play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
|
38
|
+
ChartColumn: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
|
|
39
|
+
Table: '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
|
|
40
|
+
Code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
|
41
|
+
Hash: '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>',
|
|
42
|
+
Quote: '<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
|
|
43
|
+
GitCommitHorizontal: '<circle cx="12" cy="12" r="3"/><line x1="3" x2="9" y1="12" y2="12"/><line x1="15" x2="21" y1="12" y2="12"/>',
|
|
44
|
+
Group: '<path d="M3 7V5c0-1.1.9-2 2-2h2"/><path d="M17 3h2c1.1 0 2 .9 2 2v2"/><path d="M21 17v2c0 1.1-.9 2-2 2h-2"/><path d="M7 21H5c-1.1 0-2-.9-2-2v-2"/><rect width="7" height="5" x="7" y="7" rx="1"/><rect width="7" height="5" x="10" y="12" rx="1"/>',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
48
|
+
let serial = 0;
|
|
49
|
+
/** A unique id for this page: `prefix-1`, `prefix-2`... (one counter for every part, so ids never collide across parts). */
|
|
50
|
+
export const nextId = (prefix) => `${prefix}-${++serial}`;
|
|
51
|
+
|
|
52
|
+
/** The default class prefix of kit parts; Layout Studio passes "opf-lx". */
|
|
53
|
+
export const KIT_PREFIX = "opf-kit";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* DOM helpers bound to one document and one class prefix: `el(tag, className, attributes)` (`text` sets the text; `undefined`, `null` and
|
|
57
|
+
* `false` attributes are skipped), `icon(name, size)` (an inline Lucide icon, `aria-hidden`), and `button(className, options)` (a real
|
|
58
|
+
* button; with a `label` and only an icon, the label is its accessible name).
|
|
59
|
+
*/
|
|
60
|
+
export function createDom(doc, { prefix = KIT_PREFIX } = {}) {
|
|
61
|
+
const el = (tag, className, attributes = {}) => {
|
|
62
|
+
const node = doc.createElement(tag);
|
|
63
|
+
if (className) node.className = className;
|
|
64
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
65
|
+
if (value === undefined || value === null || value === false) continue;
|
|
66
|
+
if (key === "text") node.textContent = value; else node.setAttribute(key, value === true ? "" : String(value));
|
|
67
|
+
}
|
|
68
|
+
return node;
|
|
69
|
+
};
|
|
70
|
+
const icon = (name, size = 16) => {
|
|
71
|
+
const svg = doc.createElementNS(SVG, "svg");
|
|
72
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
73
|
+
svg.setAttribute("width", String(size));
|
|
74
|
+
svg.setAttribute("height", String(size));
|
|
75
|
+
svg.setAttribute("fill", "none");
|
|
76
|
+
svg.setAttribute("stroke", "currentColor");
|
|
77
|
+
svg.setAttribute("stroke-width", "2");
|
|
78
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
79
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
80
|
+
svg.setAttribute("aria-hidden", "true");
|
|
81
|
+
svg.setAttribute("focusable", "false");
|
|
82
|
+
svg.setAttribute("class", `${prefix}-icon ${prefix}-icon-${name.toLowerCase()}`);
|
|
83
|
+
svg.innerHTML = ICONS[name] ?? "";
|
|
84
|
+
return svg;
|
|
85
|
+
};
|
|
86
|
+
const button = (className, { label, text, iconName, size = 16, pressed, title, attributes = {} } = {}) => {
|
|
87
|
+
const node = el("button", className, { type: "button", "aria-label": label, title: title ?? (text ? undefined : label), ...attributes });
|
|
88
|
+
if (pressed !== undefined) node.setAttribute("aria-pressed", String(pressed));
|
|
89
|
+
if (iconName) node.append(icon(iconName, size));
|
|
90
|
+
if (text) node.append(el("span", `${prefix}-button-text`, { text }));
|
|
91
|
+
return node;
|
|
92
|
+
};
|
|
93
|
+
return { doc, prefix, el, icon, button };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether keyboard events from this element belong to a field the person is typing in (so a shortcut must not fire). */
|
|
97
|
+
export function isTypingTarget(target) {
|
|
98
|
+
if (!target || target.nodeType !== 1) return false;
|
|
99
|
+
const tag = target.tagName;
|
|
100
|
+
if (tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
101
|
+
if (tag === "INPUT") return !["checkbox", "radio", "button", "submit", "reset"].includes(target.type);
|
|
102
|
+
return target.isContentEditable === true;
|
|
103
|
+
}
|
|
104
|
+
export const isMac = (win) => /Mac|iPhone|iPad/.test(win?.navigator?.platform ?? "");
|
|
105
|
+
|
|
106
|
+
/** A unit fraction as a CSS percentage, rounded to 0.01%. */
|
|
107
|
+
export const pct = (fraction) => `${Math.round(fraction * 10000) / 100}%`;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A menu (`role="menu"`) at a client point, inside `host` (a positioned element). Items are `{ label, onSelect, disabled, role, checked, hint }`.
|
|
111
|
+
* Arrow keys, Home, End, Enter and Space work; Escape closes and returns focus to `returnFocus`; a press outside closes it. Returns
|
|
112
|
+
* `{ element, close }`.
|
|
113
|
+
*/
|
|
114
|
+
export function openMenu(dom, host, { x, y, items, label, returnFocus, onClose, className = "" }) {
|
|
115
|
+
const p = dom.prefix;
|
|
116
|
+
const menu = dom.el("div", `${p}-menu ${className}`.trim(), { role: "menu", "aria-label": label });
|
|
117
|
+
const buttons = items.map((item) => {
|
|
118
|
+
const role = item.role ?? "menuitem";
|
|
119
|
+
const node = dom.el("button", `${p}-menuitem`, { type: "button", role, tabindex: "-1", "aria-disabled": item.disabled ? "true" : undefined, "aria-checked": role === "menuitemcheckbox" ? String(Boolean(item.checked)) : undefined });
|
|
120
|
+
node.append(dom.el("span", `${p}-menu-label`, { text: item.label }));
|
|
121
|
+
if (item.hint) node.append(dom.el("span", `${p}-menu-hint`, { text: item.hint }));
|
|
122
|
+
if (role === "menuitemcheckbox") node.prepend(dom.icon(item.checked ? "Check" : "Square", 14));
|
|
123
|
+
node.addEventListener("click", () => {
|
|
124
|
+
if (item.disabled) return;
|
|
125
|
+
close();
|
|
126
|
+
item.onSelect?.();
|
|
127
|
+
});
|
|
128
|
+
return node;
|
|
129
|
+
});
|
|
130
|
+
menu.append(...buttons);
|
|
131
|
+
let closed = false;
|
|
132
|
+
const outside = (event) => { if (!menu.contains(event.target)) close({ restore: false }); };
|
|
133
|
+
function close({ restore = true } = {}) {
|
|
134
|
+
if (closed) return;
|
|
135
|
+
closed = true;
|
|
136
|
+
dom.doc.removeEventListener("pointerdown", outside, true);
|
|
137
|
+
menu.remove();
|
|
138
|
+
if (restore) returnFocus?.focus?.({ preventScroll: true });
|
|
139
|
+
onClose?.();
|
|
140
|
+
}
|
|
141
|
+
const enabled = () => buttons.filter((node) => node.getAttribute("aria-disabled") !== "true");
|
|
142
|
+
menu.addEventListener("keydown", (event) => {
|
|
143
|
+
const list = enabled();
|
|
144
|
+
const at = list.indexOf(dom.doc.activeElement);
|
|
145
|
+
const go = (index) => { event.preventDefault(); event.stopPropagation(); list[(index + list.length) % list.length]?.focus(); };
|
|
146
|
+
if (event.key === "ArrowDown") go(at + 1);
|
|
147
|
+
else if (event.key === "ArrowUp") go(at < 0 ? -1 : at - 1);
|
|
148
|
+
else if (event.key === "Home") go(0);
|
|
149
|
+
else if (event.key === "End") go(-1);
|
|
150
|
+
else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
|
151
|
+
else if (event.key === "Tab") close({ restore: false });
|
|
152
|
+
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); if (dom.doc.activeElement?.click) dom.doc.activeElement.click(); }
|
|
153
|
+
});
|
|
154
|
+
host.append(menu);
|
|
155
|
+
const bounds = host.getBoundingClientRect();
|
|
156
|
+
const width = menu.offsetWidth || 200, height = menu.offsetHeight || buttons.length * 32;
|
|
157
|
+
menu.style.left = `${Math.max(4, Math.min(x - bounds.left, bounds.width - width - 4))}px`;
|
|
158
|
+
menu.style.top = `${Math.max(4, Math.min(y - bounds.top, bounds.height - height - 4))}px`;
|
|
159
|
+
dom.doc.addEventListener("pointerdown", outside, true);
|
|
160
|
+
enabled()[0]?.focus();
|
|
161
|
+
return { element: menu, close };
|
|
162
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): the drag controller (threshold, touch hold, trial drop, Escape), pointer tracking and the drag label. */
|
|
2
|
+
import type { KitDom } from "./dom.js";
|
|
3
|
+
export interface KitPress<Payload> { x: number; y: number; id: number; type: string; time: number; payload: Payload }
|
|
4
|
+
export interface KitDragOptions<Payload, Session> {
|
|
5
|
+
doc?: Document;
|
|
6
|
+
threshold?: number;
|
|
7
|
+
holdMs?: number;
|
|
8
|
+
now?(): number;
|
|
9
|
+
onStart?(press: KitPress<Payload>): Session | null | undefined;
|
|
10
|
+
onStarted?(session: Session): void;
|
|
11
|
+
onMove?(session: Session, event: PointerEvent): void;
|
|
12
|
+
onEnd?(session: Session, info: { cancelled: boolean }): void;
|
|
13
|
+
onDrop?(session: Session, event: PointerEvent): void;
|
|
14
|
+
onEscape?(session: Session): void;
|
|
15
|
+
}
|
|
16
|
+
export interface KitDragController<Payload, Session> {
|
|
17
|
+
readonly active: Session | null;
|
|
18
|
+
readonly pending: KitPress<Payload> | null;
|
|
19
|
+
press(event: PointerEvent, payload: Payload): void;
|
|
20
|
+
move(event: PointerEvent): boolean;
|
|
21
|
+
release(event: PointerEvent): boolean;
|
|
22
|
+
cancel(): boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function createDragController<Payload, Session>(options?: KitDragOptions<Payload, Session>): KitDragController<Payload, Session>;
|
|
25
|
+
export declare function trackPointer(node: HTMLElement, options: { doc?: Document; onMove(event: PointerEvent): void; onEnd(commit: boolean): void }): { cancel(): void };
|
|
26
|
+
export declare function dragGhost(dom: KitDom, host: HTMLElement, text: string): { element: HTMLElement; moveTo(event: { clientX: number; clientY: number }): void; remove(): void };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// CE-18, the studio kit: dragging. `createDragController` turns a press into a drag once the pointer moves past a threshold (on touch, after a
|
|
2
|
+
// hold, so a scroll is never taken for a move), runs the host's trial drop on each move, lands it once on release, and cancels on Escape.
|
|
3
|
+
// `trackPointer` follows one captured pointer (a divider) until it is released or cancelled. `dragGhost` is the label that follows the
|
|
4
|
+
// pointer. No document knowledge: the host decides what a drag carries and what a drop does.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A drag controller. Options:
|
|
8
|
+
* - `doc`: the document (for the Escape key while dragging);
|
|
9
|
+
* - `threshold`: the distance in pixels before a press becomes a drag (default 4);
|
|
10
|
+
* - `holdMs`: on touch, how long the press must be held before movement drags (default 0); moving earlier abandons the press;
|
|
11
|
+
* - `onStart(pending)`: make the drag session from the press (`{ x, y, id, type, time, payload }`); null or undefined refuses it;
|
|
12
|
+
* - `onStarted(session)`: after the controller holds the session (paint the dragging state here);
|
|
13
|
+
* - `onMove(session, event)`: each move while dragging (the trial drop);
|
|
14
|
+
* - `onEnd(session, { cancelled })`: clean up, before `onDrop` or after a cancel;
|
|
15
|
+
* - `onDrop(session, event)`: the release (land the drop);
|
|
16
|
+
* - `onEscape(session)`: after Escape cancelled the drag.
|
|
17
|
+
* The host forwards its pointer events: `press(event, payload)`, `move(event)` (true while dragging), `release(event)` (true when it ended a
|
|
18
|
+
* drag) and `cancel()`.
|
|
19
|
+
*/
|
|
20
|
+
export function createDragController(options = {}) {
|
|
21
|
+
const threshold = options.threshold ?? 4;
|
|
22
|
+
const holdMs = options.holdMs ?? 0;
|
|
23
|
+
const now = options.now ?? (() => Date.now());
|
|
24
|
+
let pending = null;
|
|
25
|
+
let active = null;
|
|
26
|
+
|
|
27
|
+
const onKey = (event) => {
|
|
28
|
+
if (event.key !== "Escape" || !active) return;
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
event.stopPropagation();
|
|
31
|
+
const session = active;
|
|
32
|
+
controller.cancel();
|
|
33
|
+
options.onEscape?.(session);
|
|
34
|
+
};
|
|
35
|
+
const listen = (on) => { if (options.doc) (on ? options.doc.addEventListener : options.doc.removeEventListener).call(options.doc, "keydown", onKey, true); };
|
|
36
|
+
|
|
37
|
+
function start() {
|
|
38
|
+
const session = options.onStart ? options.onStart(pending) : pending;
|
|
39
|
+
if (session === null || session === undefined) { pending = null; return false; }
|
|
40
|
+
active = session;
|
|
41
|
+
listen(true);
|
|
42
|
+
options.onStarted?.(session);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
function end(cancelled) {
|
|
46
|
+
const session = active;
|
|
47
|
+
listen(false);
|
|
48
|
+
active = null;
|
|
49
|
+
pending = null;
|
|
50
|
+
options.onEnd?.(session, { cancelled });
|
|
51
|
+
return session;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const controller = {
|
|
55
|
+
get active() { return active; },
|
|
56
|
+
get pending() { return pending; },
|
|
57
|
+
/** A press that may become a drag; `payload` is what the drag would carry. */
|
|
58
|
+
press(event, payload) {
|
|
59
|
+
pending = { x: event.clientX, y: event.clientY, id: event.pointerId, type: event.pointerType, time: now(), payload };
|
|
60
|
+
},
|
|
61
|
+
/** A move: starts the drag past the threshold, then reports each move. Returns true while a drag runs. */
|
|
62
|
+
move(event) {
|
|
63
|
+
if (pending && !active && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) > threshold) {
|
|
64
|
+
if (holdMs > 0 && pending.type === "touch" && now() - pending.time < holdMs) pending = null;
|
|
65
|
+
else start();
|
|
66
|
+
}
|
|
67
|
+
if (!active) return false;
|
|
68
|
+
options.onMove?.(active, event);
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
/** The release: lands the drag (`onEnd`, then `onDrop`) and returns true, or forgets the press and returns false. */
|
|
72
|
+
release(event) {
|
|
73
|
+
if (!active) { pending = null; return false; }
|
|
74
|
+
const session = end(false);
|
|
75
|
+
options.onDrop?.(session, event);
|
|
76
|
+
return true;
|
|
77
|
+
},
|
|
78
|
+
/** Drop the press, and the drag if one runs (`onEnd` with `cancelled`). */
|
|
79
|
+
cancel() {
|
|
80
|
+
if (!active) { pending = null; return false; }
|
|
81
|
+
end(true);
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return controller;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Follow one captured pointer on `node` from a press: `onMove(event)` on each move, then `onEnd(commit)`, true on release and false on
|
|
90
|
+
* cancel or Escape. Returns `{ cancel }`.
|
|
91
|
+
*/
|
|
92
|
+
export function trackPointer(node, { doc, onMove, onEnd }) {
|
|
93
|
+
let done = false;
|
|
94
|
+
const finish = (commit) => {
|
|
95
|
+
if (done) return;
|
|
96
|
+
done = true;
|
|
97
|
+
node.removeEventListener("pointermove", onMove);
|
|
98
|
+
node.removeEventListener("pointerup", onUp);
|
|
99
|
+
node.removeEventListener("pointercancel", onCancel);
|
|
100
|
+
doc?.removeEventListener("keydown", onKey, true);
|
|
101
|
+
onEnd(commit);
|
|
102
|
+
};
|
|
103
|
+
const onUp = () => finish(true);
|
|
104
|
+
const onCancel = () => finish(false);
|
|
105
|
+
const onKey = (event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); finish(false); } };
|
|
106
|
+
node.addEventListener("pointermove", onMove);
|
|
107
|
+
node.addEventListener("pointerup", onUp);
|
|
108
|
+
node.addEventListener("pointercancel", onCancel);
|
|
109
|
+
doc?.addEventListener("keydown", onKey, true);
|
|
110
|
+
return { cancel: () => finish(false) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The label that follows the pointer during a drag ("Text", "3 blocks"), inside `host` (a positioned element). */
|
|
114
|
+
export function dragGhost(dom, host, text) {
|
|
115
|
+
const ghost = dom.el("div", `${dom.prefix}-ghost`, { "aria-hidden": "true", text });
|
|
116
|
+
host.append(ghost);
|
|
117
|
+
return {
|
|
118
|
+
element: ghost,
|
|
119
|
+
/** Put the label just below and right of the pointer. */
|
|
120
|
+
moveTo(event) {
|
|
121
|
+
const box = host.getBoundingClientRect();
|
|
122
|
+
ghost.style.left = `${event.clientX - box.left + 12}px`;
|
|
123
|
+
ghost.style.top = `${event.clientY - box.top + 12}px`;
|
|
124
|
+
},
|
|
125
|
+
remove() { ghost.remove(); },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): gesture history (drafts while a gesture runs, one step at its end) and a snapshot history. */
|
|
2
|
+
export interface KitGestureAdapter<Snapshot, Draft> {
|
|
3
|
+
snapshot(): Snapshot;
|
|
4
|
+
preview(draft: Draft, message?: string): void;
|
|
5
|
+
commit(start: Snapshot, info: { label: string; message: string }): boolean;
|
|
6
|
+
restore(start: Snapshot): void;
|
|
7
|
+
}
|
|
8
|
+
export interface KitGesture<Draft> {
|
|
9
|
+
readonly active: boolean;
|
|
10
|
+
readonly label: string | null;
|
|
11
|
+
begin(label: string): boolean;
|
|
12
|
+
update(draft: Draft, message?: string): boolean;
|
|
13
|
+
end(message?: string): boolean;
|
|
14
|
+
cancel(): boolean;
|
|
15
|
+
reset(): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function createGesture<Snapshot, Draft>(adapter: KitGestureAdapter<Snapshot, Draft>): KitGesture<Draft>;
|
|
18
|
+
export interface KitHistory<Snapshot extends object> {
|
|
19
|
+
readonly index: number;
|
|
20
|
+
readonly size: number;
|
|
21
|
+
readonly head: (Snapshot & { coalesce?: string | null; time?: number }) | undefined;
|
|
22
|
+
reset(snapshot: Snapshot): void;
|
|
23
|
+
push(snapshot: Snapshot, coalesce?: string | null): void;
|
|
24
|
+
append(snapshot: Snapshot): void;
|
|
25
|
+
canUndo(): boolean;
|
|
26
|
+
canRedo(): boolean;
|
|
27
|
+
go(step: number): (Snapshot & { coalesce?: string | null; time?: number }) | null;
|
|
28
|
+
}
|
|
29
|
+
export declare function createHistory<Snapshot extends object>(options?: { limit?: number; coalesceMs?: number; now?(): number }): KitHistory<Snapshot>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// CE-18, the studio kit: gesture history. A gesture (a divider drag, a scrub) shows drafts while it runs and lands as one history step;
|
|
2
|
+
// `createGesture` is that begin / update / end / cancel cycle over an adapter. `createHistory` is the snapshot history with coalescing that an
|
|
3
|
+
// adapter without an editor session keeps (Layout Studio's record); a slide adapter uses the session's history instead. No DOM.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A gesture over `adapter`:
|
|
7
|
+
* - `snapshot()`: the state to return to on cancel (taken at `begin`);
|
|
8
|
+
* - `preview(draft, message)`: show a draft (no history, no change callbacks);
|
|
9
|
+
* - `commit(start, { label, message })`: land the gesture as one step; returns whether anything changed;
|
|
10
|
+
* - `restore(start)`: put the state taken at `begin` back.
|
|
11
|
+
*/
|
|
12
|
+
export function createGesture(adapter) {
|
|
13
|
+
let active = null;
|
|
14
|
+
return {
|
|
15
|
+
get active() { return active !== null; },
|
|
16
|
+
get label() { return active?.label ?? null; },
|
|
17
|
+
/** Start a gesture; false while one is running. */
|
|
18
|
+
begin(label) {
|
|
19
|
+
if (active) return false;
|
|
20
|
+
active = { label, start: adapter.snapshot() };
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
/** Show a draft; false when no gesture runs. */
|
|
24
|
+
update(draft, message) {
|
|
25
|
+
if (!active) return false;
|
|
26
|
+
adapter.preview(draft, message);
|
|
27
|
+
return true;
|
|
28
|
+
},
|
|
29
|
+
/** Land the gesture as one step (`message`, else the label). Returns what `commit` returned, false when no gesture ran. */
|
|
30
|
+
end(message) {
|
|
31
|
+
if (!active) return false;
|
|
32
|
+
const { start, label } = active;
|
|
33
|
+
active = null;
|
|
34
|
+
return Boolean(adapter.commit(start, { label, message: message ?? label }));
|
|
35
|
+
},
|
|
36
|
+
/** Drop the gesture and restore the state from its start. */
|
|
37
|
+
cancel() {
|
|
38
|
+
if (!active) return false;
|
|
39
|
+
const { start } = active;
|
|
40
|
+
active = null;
|
|
41
|
+
adapter.restore(start);
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
/** Forget a running gesture without restoring anything (the document was replaced under it). */
|
|
45
|
+
reset() { active = null; },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A snapshot history: `reset(snapshot)` starts it, `push(snapshot, coalesce)` adds a step (a step with the same `coalesce` key within
|
|
51
|
+
* `coalesceMs` of the head replaces it, so typing in a field is one step), `append(snapshot)` adds one after the current step without the
|
|
52
|
+
* limit, and `go(-1 | 1)` moves and returns the step (null past either end). At most `limit` steps are kept; the oldest are dropped.
|
|
53
|
+
*/
|
|
54
|
+
export function createHistory({ limit = 200, coalesceMs = 1500, now = () => Date.now() } = {}) {
|
|
55
|
+
let entries = [];
|
|
56
|
+
let index = 0;
|
|
57
|
+
return {
|
|
58
|
+
get index() { return index; },
|
|
59
|
+
get size() { return entries.length; },
|
|
60
|
+
get head() { return entries[index]; },
|
|
61
|
+
reset(snapshot) { entries = [{ ...snapshot }]; index = 0; },
|
|
62
|
+
push(snapshot, coalesce) {
|
|
63
|
+
const head = entries[index];
|
|
64
|
+
const time = now();
|
|
65
|
+
if (coalesce && head?.coalesce === coalesce && time - head.time < coalesceMs && index === entries.length - 1 && index > 0) {
|
|
66
|
+
entries[index] = { ...snapshot, coalesce, time };
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
entries.length = index + 1;
|
|
70
|
+
entries.push({ ...snapshot, coalesce, time });
|
|
71
|
+
if (entries.length > limit) entries.shift(); else index++;
|
|
72
|
+
},
|
|
73
|
+
append(snapshot) {
|
|
74
|
+
entries.length = index + 1;
|
|
75
|
+
entries.push({ ...snapshot, time: now() });
|
|
76
|
+
index++;
|
|
77
|
+
},
|
|
78
|
+
canUndo: () => index > 0,
|
|
79
|
+
canRedo: () => index < entries.length - 1,
|
|
80
|
+
go(step) {
|
|
81
|
+
const target = index + step;
|
|
82
|
+
if (target < 0 || target >= entries.length) return null;
|
|
83
|
+
index = target;
|
|
84
|
+
return entries[target];
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** CE-18: the studio kit (internal; no package subpath exports it yet). The document adapter a studio supplies is `StudioDocumentAdapter`. */
|
|
2
|
+
export * from "./dom.js";
|
|
3
|
+
export * from "./commands.js";
|
|
4
|
+
export * from "./selection.js";
|
|
5
|
+
export * from "./gesture.js";
|
|
6
|
+
export * from "./viewport.js";
|
|
7
|
+
export * from "./overlay.js";
|
|
8
|
+
export * from "./drag.js";
|
|
9
|
+
export * from "./tree.js";
|
|
10
|
+
export * from "./inspector.js";
|
|
11
|
+
import type { KitBox } from "./overlay.js";
|
|
12
|
+
|
|
13
|
+
/** One node of a studio document, as the layers, the overlay and the screen reader see it. */
|
|
14
|
+
export interface StudioNode<Key = string> { key: string; path: Key; kind: string; parent: string | null; depth: number; label: string; findings: { message: string }[] }
|
|
15
|
+
/** What a studio needs from a document (Layout Studio: src/layout-adapter.js; the slide adapter is CE-19). */
|
|
16
|
+
export interface StudioDocumentAdapter<Key, Draft = unknown> {
|
|
17
|
+
nodes(): StudioNode<Key>[];
|
|
18
|
+
boxOf(key: Key): (KitBox & { depth: number }) | undefined;
|
|
19
|
+
containerOf(key: Key): { direction: "row" | "column"; tracks: { start: number; length: number }[] } | undefined;
|
|
20
|
+
hitChain(point: { x: number; y: number }, skip?: (box: KitBox) => boolean): (KitBox & { depth: number })[];
|
|
21
|
+
trialMove(keys: Key[], target: Key, index: number): { ok: boolean; reason: string; noop: boolean };
|
|
22
|
+
run(command: string, ...args: unknown[]): boolean;
|
|
23
|
+
begin(label: string): boolean;
|
|
24
|
+
preview(draft: Draft, message?: string): void;
|
|
25
|
+
commit(message?: string): boolean;
|
|
26
|
+
cancel(): void;
|
|
27
|
+
undo(): boolean;
|
|
28
|
+
redo(): boolean;
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// CE-18: the studio kit, the parts Layout Studio and the Studio share (STUDY.md 3.6, STUDIO-SPEC.md 17). It is internal for now: no package
|
|
2
|
+
// subpath exports it until a second studio uses it (then `@openpresentation/opf-editor/studio-kit`). Nothing here knows a document format;
|
|
3
|
+
// a studio supplies a document adapter (src/layout-adapter.js for Layout Studio).
|
|
4
|
+
export { ICONS, KIT_PREFIX, createDom, isMac, isTypingTarget, nextId, openMenu, pct } from "./dom.js";
|
|
5
|
+
export { createCommandRegistry, formatKey, isHandled, keyGuard, markHandled, matchKey, parseKey } from "./commands.js";
|
|
6
|
+
export { clickTarget, createSelection } from "./selection.js";
|
|
7
|
+
export { createGesture, createHistory } from "./gesture.js";
|
|
8
|
+
export { ZOOM_STEPS, clampZoom, createViewport, scrollForZoom, stepZoom, toUnit } from "./viewport.js";
|
|
9
|
+
export { boxesAt, createKeyedLayer, createOverlay, dividerKeyAction, inside, paintBadge, paintDivider, paintSelectable, ratioLabel, setBox } from "./overlay.js";
|
|
10
|
+
export { createDragController, dragGhost, trackPointer } from "./drag.js";
|
|
11
|
+
export { hitRow, rovingTabStop, rowDropMode, showTreeDrop, treeItem, treeKeyAction } from "./tree.js";
|
|
12
|
+
export { createFields, createInspectorFrame } from "./inspector.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): the inspector frame (sections chosen by the selection) and its field builders. */
|
|
2
|
+
import type { KitDom } from "./dom.js";
|
|
3
|
+
export interface KitInspectorSection<Ctx> { id: string; when?(ctx: Ctx): boolean; render(body: HTMLElement, ctx: Ctx): void }
|
|
4
|
+
export interface KitInspectorFrame<Ctx> {
|
|
5
|
+
readonly editing: boolean;
|
|
6
|
+
sectionsFor(ctx: Ctx): KitInspectorSection<Ctx>[];
|
|
7
|
+
register(section: KitInspectorSection<Ctx>, options?: { before?: string }): () => void;
|
|
8
|
+
render(): void;
|
|
9
|
+
update(): void;
|
|
10
|
+
check(fn: () => void): void;
|
|
11
|
+
edit(callback: () => void): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function createInspectorFrame<Ctx>(body: HTMLElement, options?: { sections?: KitInspectorSection<Ctx>[]; context?(): Ctx; before?(ctx: Ctx): void; after?(ctx: Ctx): void }): KitInspectorFrame<Ctx>;
|
|
14
|
+
export interface KitFields<Path> {
|
|
15
|
+
field(label: string, control: HTMLElement, options?: { help?: string; path?: Path; field?: string; labelFor?: string; group?: boolean; target?: HTMLElement }): HTMLElement & { error: HTMLElement };
|
|
16
|
+
textInput(value: string | undefined, options?: { onInput?(value: string): void; name?: string; pattern?: string; placeholder?: string; multiline?: boolean }): HTMLInputElement | HTMLTextAreaElement;
|
|
17
|
+
select(entries: [string, string][], current: string, options: { onChange(value: string): void; name: string }): HTMLSelectElement;
|
|
18
|
+
segmented(entries: [string, string, string?][], current: string, options: { onPick(value: string): void; label: string; name: string }): HTMLElement;
|
|
19
|
+
actionButton(text: string, iconName: string | undefined, run: () => void, options?: { label?: string; disabled?: boolean; shortcut?: string }): HTMLButtonElement;
|
|
20
|
+
section(label?: string): HTMLElement;
|
|
21
|
+
}
|
|
22
|
+
export declare function createFields<Path>(dom: KitDom, frame: KitInspectorFrame<unknown>, options?: { idPrefix?: string; issues?(path: Path, field: string): { message: string }[]; readOnly?(): boolean }): KitFields<Path>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// CE-18, the studio kit: the inspector frame. Sections are registered with `when(ctx)` and `render(body, ctx)`; every section whose `when`
|
|
2
|
+
// holds for the current selection is drawn, in order. While the person types in a field of the panel, a change only refreshes the findings
|
|
3
|
+
// under the fields (`update`), so the field keeps its focus and caret; any other change redraws the panel and puts focus back on the same
|
|
4
|
+
// field. `createFields` builds the labeled fields, with core's findings shown under each. Plain DOM, styled by class prefix.
|
|
5
|
+
import { nextId } from "./dom.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The frame over `body`. Options: `sections` (`{ id, when(ctx), render(body, ctx) }`, in order), `context()` (the ctx of a redraw),
|
|
9
|
+
* `before(ctx)` and `after(ctx)` (around the sections: a title, findings that fit no field). Returns `{ render, update, edit, check, editing,
|
|
10
|
+
* sections }`: `edit(callback)` runs an edit from a typed field, `check(fn)` adds a findings check that `update` runs.
|
|
11
|
+
*/
|
|
12
|
+
export function createInspectorFrame(body, { sections = [], context = () => ({}), before, after } = {}) {
|
|
13
|
+
const list = [...sections];
|
|
14
|
+
let checks = [];
|
|
15
|
+
let editing = false;
|
|
16
|
+
const frame = {
|
|
17
|
+
get editing() { return editing; },
|
|
18
|
+
/** The sections drawn for `ctx`, in order. */
|
|
19
|
+
sectionsFor: (ctx) => list.filter((section) => section.when ? section.when(ctx) === true : true),
|
|
20
|
+
/** Add a section (before the one with id `before`, else at the end). Returns the function that removes it. */
|
|
21
|
+
register(section, { before: id } = {}) {
|
|
22
|
+
const at = id ? list.findIndex((entry) => entry.id === id) : -1;
|
|
23
|
+
if (at >= 0) list.splice(at, 0, section); else list.push(section);
|
|
24
|
+
return () => { const index = list.indexOf(section); if (index >= 0) list.splice(index, 1); };
|
|
25
|
+
},
|
|
26
|
+
render() {
|
|
27
|
+
const doc = body.ownerDocument;
|
|
28
|
+
const active = doc.activeElement;
|
|
29
|
+
const focusKey = body.contains(active) ? active.dataset?.field : undefined;
|
|
30
|
+
checks = [];
|
|
31
|
+
body.replaceChildren();
|
|
32
|
+
const ctx = context();
|
|
33
|
+
before?.(ctx);
|
|
34
|
+
for (const section of frame.sectionsFor(ctx)) section.render(body, ctx);
|
|
35
|
+
after?.(ctx);
|
|
36
|
+
frame.update();
|
|
37
|
+
if (focusKey) body.querySelector(`[data-field="${focusKey}"]`)?.focus({ preventScroll: true });
|
|
38
|
+
},
|
|
39
|
+
update() { for (const check of checks) check(); },
|
|
40
|
+
check(fn) { checks.push(fn); },
|
|
41
|
+
edit(callback) { editing = true; try { callback(); } finally { editing = false; } },
|
|
42
|
+
};
|
|
43
|
+
return frame;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Field builders bound to a frame. Options: `idPrefix` (field ids), `issues(path, field)` (the findings of one field, `{ message }`),
|
|
48
|
+
* `readOnly()`. Each builder returns an element; `field` wraps a control with its label, help and the live findings line.
|
|
49
|
+
*/
|
|
50
|
+
export function createFields(dom, frame, { idPrefix = nextId(`${dom.prefix}-fields`), issues = () => [], readOnly = () => false } = {}) {
|
|
51
|
+
const { el } = dom;
|
|
52
|
+
const p = dom.prefix;
|
|
53
|
+
/** A labeled field. `path`/`field` tie it to the findings: they show under the field and mark it invalid. */
|
|
54
|
+
function field(label, control, { help, path, field: key, labelFor, group, target } = {}) {
|
|
55
|
+
const wrap = el("div", `${p}-field`, { "data-field-name": key ?? "" });
|
|
56
|
+
const fieldId = `${idPrefix}-${nextId("f")}`;
|
|
57
|
+
const focusTarget = target ?? control;
|
|
58
|
+
if (group) {
|
|
59
|
+
const caption = el("span", `${p}-label-text`, { id: `${fieldId}-label`, text: label });
|
|
60
|
+
control.setAttribute("aria-labelledby", `${fieldId}-label`);
|
|
61
|
+
if (!control.getAttribute("role")) control.setAttribute("role", "group");
|
|
62
|
+
wrap.append(caption);
|
|
63
|
+
} else {
|
|
64
|
+
if (!labelFor && !control.id) control.id = fieldId;
|
|
65
|
+
wrap.append(el("label", `${p}-label-text`, { for: labelFor ?? control.id, text: label }));
|
|
66
|
+
}
|
|
67
|
+
wrap.append(control);
|
|
68
|
+
if (help) { const note = el("p", `${p}-help`, { id: `${fieldId}-help`, text: help }); wrap.append(note); if (!group) focusTarget.setAttribute("aria-describedby", note.id); }
|
|
69
|
+
const error = el("p", `${p}-field-error`, { id: `${fieldId}-error`, role: "status", "aria-live": "polite" });
|
|
70
|
+
error.hidden = true;
|
|
71
|
+
wrap.append(error);
|
|
72
|
+
if (path && key) {
|
|
73
|
+
frame.check(() => {
|
|
74
|
+
const found = issues(path, key);
|
|
75
|
+
error.hidden = !found.length;
|
|
76
|
+
error.replaceChildren();
|
|
77
|
+
if (found.length) error.append(dom.icon("CircleX", 14), el("span", "", { text: found.map((issue) => issue.message).join(" ") }));
|
|
78
|
+
wrap.dataset.invalid = String(Boolean(found.length));
|
|
79
|
+
if (!group) { if (found.length) { focusTarget.setAttribute("aria-invalid", "true"); focusTarget.setAttribute("aria-errormessage", error.id); } else { focusTarget.removeAttribute("aria-invalid"); focusTarget.removeAttribute("aria-errormessage"); } }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return Object.assign(wrap, { error });
|
|
83
|
+
}
|
|
84
|
+
/** A text field (or a textarea, `multiline`); `onInput(value)` runs as a typed edit. */
|
|
85
|
+
function textInput(value, { onInput, name, pattern, placeholder, multiline } = {}) {
|
|
86
|
+
const input = el(multiline ? "textarea" : "input", `${p}-input`, { type: multiline ? undefined : "text", "data-field": name, pattern, placeholder, spellcheck: "false", autocomplete: "off", rows: multiline ? 2 : undefined });
|
|
87
|
+
input.value = value ?? "";
|
|
88
|
+
input.addEventListener("input", () => frame.edit(() => onInput(input.value)));
|
|
89
|
+
return input;
|
|
90
|
+
}
|
|
91
|
+
/** A select of `[value, label]` entries. */
|
|
92
|
+
function select(entries, current, { onChange, name }) {
|
|
93
|
+
const input = el("select", `${p}-select`, { "data-field": name });
|
|
94
|
+
for (const [value, label] of entries) { const option = el("option", "", { value, text: label }); input.append(option); }
|
|
95
|
+
input.value = current;
|
|
96
|
+
input.addEventListener("change", () => onChange(input.value));
|
|
97
|
+
return input;
|
|
98
|
+
}
|
|
99
|
+
/** A segmented control of `[value, text, iconName]` entries (toggle buttons, one pressed). */
|
|
100
|
+
function segmented(entries, current, { onPick, label, name }) {
|
|
101
|
+
const wrap = el("div", `${p}-segmented`, { role: "group", "aria-label": label });
|
|
102
|
+
for (const [value, text, iconName] of entries) {
|
|
103
|
+
const button = dom.button(`${p}-segment`, { text, iconName, size: 14, pressed: value === current, attributes: { "data-field": `${name}-${value}`, "data-value": value } });
|
|
104
|
+
button.addEventListener("click", () => { if (value !== current) onPick(value); });
|
|
105
|
+
wrap.append(button);
|
|
106
|
+
}
|
|
107
|
+
return wrap;
|
|
108
|
+
}
|
|
109
|
+
/** An action button, disabled when `disabled` or read only. */
|
|
110
|
+
const actionButton = (text, iconName, run, { label, disabled, shortcut } = {}) => {
|
|
111
|
+
const button = dom.button(`${p}-action`, { text, iconName, size: 14, attributes: { "aria-label": label, "aria-keyshortcuts": shortcut } });
|
|
112
|
+
button.disabled = Boolean(disabled) || readOnly();
|
|
113
|
+
button.addEventListener("click", () => run());
|
|
114
|
+
return button;
|
|
115
|
+
};
|
|
116
|
+
/** A section of the panel, with an optional title. */
|
|
117
|
+
const section = (label) => { const node = el("div", `${p}-section`); if (label) node.append(el("h3", `${p}-section-title`, { text: label })); return node; };
|
|
118
|
+
return { field, textInput, select, segmented, actionButton, section };
|
|
119
|
+
}
|