@openpresentation/opf-editor 0.26.10 → 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 +60 -6
- package/dist/canvas-variables.d.ts +15 -0
- package/dist/canvas-variables.js +52 -0
- package/dist/canvas.js +33 -5
- package/dist/design-controls.js +3 -2
- package/dist/design-options.js +1 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +50 -3
- 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 +38 -0
- package/dist/react.js +127 -0
- package/dist/slide-retention.js +13 -0
- 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 +325 -7
- package/dist/workspace-layouts.d.ts +31 -0
- package/dist/workspace-layouts.js +84 -0
- package/dist/workspace-measure.js +35 -0
- package/dist/workspace-shell.js +96 -0
- package/dist/workspace-source.js +83 -0
- package/dist/workspace-theme.js +142 -0
- package/dist/workspace.d.ts +413 -0
- package/dist/workspace.js +1274 -0
- package/package.json +29 -11
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): boxes, badges, dividers, the drop line, the ratio badge and the marker layer over a frame. */
|
|
2
|
+
import type { KitDom } from "./dom.js";
|
|
3
|
+
export interface KitBox { x: number; y: number; w: number; h: number }
|
|
4
|
+
export declare function setBox(node: HTMLElement, box: KitBox, pad?: number): void;
|
|
5
|
+
export declare function inside(box: KitBox, point: { x: number; y: number }, slack?: number): boolean;
|
|
6
|
+
export declare function boxesAt<B extends KitBox & { depth: number }>(boxes: B[], point: { x: number; y: number }, options?: { slack?: number; skip?(box: B): boolean }): B[];
|
|
7
|
+
export interface KitKeyedPass { ensure<E extends Element>(key: string, create: () => E): E; done(): void }
|
|
8
|
+
export interface KitKeyedLayer { get(key: string): Element | undefined; entries(): IterableIterator<[string, Element]>; begin(): KitKeyedPass }
|
|
9
|
+
export declare function createKeyedLayer(layer: HTMLElement): KitKeyedLayer;
|
|
10
|
+
export declare function paintSelectable(node: HTMLElement, options: { box?: KitBox; selected: boolean; pressable?: boolean; label?: string; tabStop?: boolean }): void;
|
|
11
|
+
export declare function paintBadge(dom: KitDom, node: HTMLElement, messages: string[]): HTMLElement | null;
|
|
12
|
+
export declare function paintDivider(node: HTMLElement, options: { box?: KitBox; orientation: "vertical" | "horizontal"; draggable: boolean; tabbable?: boolean; label?: string; valueNow?: number; valueText?: string; title?: string }): void;
|
|
13
|
+
export declare function dividerKeyAction(event: Pick<KeyboardEvent, "key" | "shiftKey" | "altKey" | "ctrlKey" | "metaKey">, orientation: "vertical" | "horizontal"): { type: "nudge"; step: -1 | 1; big: boolean } | { type: "equalize" } | null;
|
|
14
|
+
export declare function ratioLabel(a: number, b: number): string;
|
|
15
|
+
export interface KitOverlay {
|
|
16
|
+
guide: HTMLElement | null;
|
|
17
|
+
drop: HTMLElement;
|
|
18
|
+
ratio: HTMLElement;
|
|
19
|
+
markers: HTMLElement | null;
|
|
20
|
+
showGuide(box: KitBox, axis: "row" | "column"): void;
|
|
21
|
+
hideGuide(): void;
|
|
22
|
+
showDrop(line: KitBox, options: { orientation: "vertical" | "horizontal"; state?: "ok" | "same" | "refused"; reason?: string }): void;
|
|
23
|
+
hideDrop(): void;
|
|
24
|
+
showRatio(label: string, at: { x: number; y: number }): void;
|
|
25
|
+
hideRatio(): void;
|
|
26
|
+
hover(layer: HTMLElement, node: Element | null | undefined): void;
|
|
27
|
+
into(layer: HTMLElement, node: Element | null | undefined): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function createOverlay(dom: KitDom, host: HTMLElement, options?: { guide?: string | false; markers?: boolean }): KitOverlay;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// CE-18, the studio kit: the overlay drawn over a studio's frame. Boxes are placed in unit coordinates (0 to 1 of the frame) and kept by key,
|
|
2
|
+
// so a redraw moves the existing elements instead of rebuilding them. The overlay also has the hover outline, findings badges, the dividers
|
|
3
|
+
// between tracks (role="separator", with their keyboard), the drop line and "into" highlight of a drag, the ratio badge of a resize, an
|
|
4
|
+
// optional guide line, and an optional marker layer (for comment pins, proposal ghosts and peers later). It knows boxes, not documents.
|
|
5
|
+
import { pct } from "./dom.js";
|
|
6
|
+
|
|
7
|
+
/** Place an absolutely positioned element over `box` (`{ x, y, w, h }` in unit coordinates), grown by `pad` on every side. */
|
|
8
|
+
export function setBox(node, box, pad = 0) {
|
|
9
|
+
node.style.left = pct(box.x - pad);
|
|
10
|
+
node.style.top = pct(box.y - pad);
|
|
11
|
+
node.style.width = pct(box.w + 2 * pad);
|
|
12
|
+
node.style.height = pct(box.h + 2 * pad);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether a unit point is inside `box`, with `slack` around it. */
|
|
16
|
+
export const inside = (box, point, slack = 0) => point.x >= box.x - slack && point.x <= box.x + box.w + slack && point.y >= box.y - slack && point.y <= box.y + box.h + slack;
|
|
17
|
+
|
|
18
|
+
/** The boxes under a unit point, from the outermost to the deepest (by `depth`), leaving out those `skip(box)` refuses. */
|
|
19
|
+
export const boxesAt = (boxes, point, { slack = 0.0005, skip } = {}) => boxes.filter((box) => inside(box, point, slack) && !(skip && skip(box))).sort((a, b) => a.depth - b.depth);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Keyed elements in `layer`. Each redraw is `const pass = keyed.begin()`, then `pass.ensure(key, create)` for every element in the order
|
|
23
|
+
* they should have (it creates the element the first time and returns the same one after), then `pass.done()`, which removes the elements
|
|
24
|
+
* not ensured in this pass and puts the rest in order, moving only those out of place (so focus and transitions survive).
|
|
25
|
+
*/
|
|
26
|
+
export function createKeyedLayer(layer) {
|
|
27
|
+
const keyed = new Map();
|
|
28
|
+
return {
|
|
29
|
+
get: (key) => keyed.get(key),
|
|
30
|
+
entries: () => keyed.entries(),
|
|
31
|
+
begin() {
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const order = [];
|
|
34
|
+
return {
|
|
35
|
+
ensure(key, create) {
|
|
36
|
+
let node = keyed.get(key);
|
|
37
|
+
if (!node) { node = create(); keyed.set(key, node); }
|
|
38
|
+
seen.add(key);
|
|
39
|
+
order.push(node);
|
|
40
|
+
return node;
|
|
41
|
+
},
|
|
42
|
+
done() {
|
|
43
|
+
for (const [key, node] of keyed) if (!seen.has(key)) { node.remove(); keyed.delete(key); }
|
|
44
|
+
let cursor = null;
|
|
45
|
+
for (const node of order) {
|
|
46
|
+
const expected = cursor ? cursor.nextSibling : layer.firstChild;
|
|
47
|
+
if (node.parentNode !== layer || expected !== node) layer.insertBefore(node, expected);
|
|
48
|
+
cursor = node;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Paint a selectable box: its place, `is-selected`, and what a screen reader hears. A `pressable` box is a toggle button (`aria-pressed`),
|
|
58
|
+
* a container is a `group`. `tabStop` makes it the one box in the tab order (a roving tab stop).
|
|
59
|
+
*/
|
|
60
|
+
export function paintSelectable(node, { box, selected, pressable = true, label, tabStop }) {
|
|
61
|
+
node.classList.toggle("is-selected", Boolean(selected));
|
|
62
|
+
node.setAttribute("role", pressable ? "button" : "group");
|
|
63
|
+
if (label !== undefined) node.setAttribute("aria-label", label);
|
|
64
|
+
if (pressable) node.setAttribute("aria-pressed", String(Boolean(selected))); else node.removeAttribute("aria-pressed");
|
|
65
|
+
node.tabIndex = tabStop ? 0 : -1;
|
|
66
|
+
if (box) setBox(node, box);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The findings badge of a box (the CircleX icon, with the messages as its title); removed when `messages` is empty. */
|
|
70
|
+
export function paintBadge(dom, node, messages) {
|
|
71
|
+
const selector = `:scope > .${dom.prefix}-badge`;
|
|
72
|
+
let badge = node.querySelector(selector);
|
|
73
|
+
if (messages.length && !badge) { badge = dom.el("span", `${dom.prefix}-badge`, { "aria-hidden": "true" }); badge.append(dom.icon("CircleX", 14)); node.append(badge); }
|
|
74
|
+
if (badge) { if (messages.length) badge.title = messages.join("\n"); else badge.remove(); }
|
|
75
|
+
return badge?.isConnected ? badge : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DIVIDER_ATTRIBUTES = ["aria-label", "aria-valuemin", "aria-valuemax", "aria-valuenow", "aria-valuetext", "title"];
|
|
79
|
+
/**
|
|
80
|
+
* Paint a divider between two tracks (`role="separator"`). `orientation` is the line's ("vertical" between columns). A `draggable` divider
|
|
81
|
+
* is focusable when `tabbable`, with `aria-valuenow` (the first track's share in percent) and `aria-valuetext` ("2 : 1"); a fixed one is
|
|
82
|
+
* `aria-disabled`.
|
|
83
|
+
*/
|
|
84
|
+
export function paintDivider(node, { box, orientation, draggable, tabbable = true, label, valueNow, valueText, title }) {
|
|
85
|
+
node.dataset.orientation = orientation;
|
|
86
|
+
node.dataset.fixed = draggable ? "false" : "true";
|
|
87
|
+
if (box) setBox(node, box);
|
|
88
|
+
node.setAttribute("role", "separator");
|
|
89
|
+
node.setAttribute("aria-orientation", orientation);
|
|
90
|
+
if (draggable) {
|
|
91
|
+
node.tabIndex = tabbable ? 0 : -1;
|
|
92
|
+
node.setAttribute("aria-label", label ?? "Resize");
|
|
93
|
+
node.setAttribute("aria-valuemin", "0");
|
|
94
|
+
node.setAttribute("aria-valuemax", "100");
|
|
95
|
+
node.setAttribute("aria-valuenow", String(valueNow));
|
|
96
|
+
node.setAttribute("aria-valuetext", String(valueText));
|
|
97
|
+
node.removeAttribute("aria-disabled");
|
|
98
|
+
if (title) node.title = title; else node.removeAttribute("title");
|
|
99
|
+
} else {
|
|
100
|
+
node.removeAttribute("tabindex");
|
|
101
|
+
for (const name of DIVIDER_ATTRIBUTES) node.removeAttribute(name);
|
|
102
|
+
node.setAttribute("aria-disabled", "true");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* What a key does on a focused divider whose line is `orientation`: the arrow keys across the line nudge it (`{ type: "nudge", step, big }`,
|
|
108
|
+
* Shift for a big step), Enter equalizes (`{ type: "equalize" }`), anything else (or a modifier) is null.
|
|
109
|
+
*/
|
|
110
|
+
export function dividerKeyAction(event, orientation) {
|
|
111
|
+
if (event.altKey || event.metaKey || event.ctrlKey) return null;
|
|
112
|
+
const keys = orientation === "vertical" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
|
|
113
|
+
const step = event.key === keys[0] ? -1 : event.key === keys[1] ? 1 : 0;
|
|
114
|
+
if (step) return { type: "nudge", step, big: Boolean(event.shiftKey) };
|
|
115
|
+
if (event.key === "Enter") return { type: "equalize" };
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The ratio of two track sizes as a label: "2 : 1". */
|
|
120
|
+
export const ratioLabel = (a, b) => `${trim(a)} : ${trim(b)}`;
|
|
121
|
+
const trim = (value) => String(Math.round(value * 1000) / 1000);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The overlay's own elements inside `host`, in this order: the guide line (when `guide` names its class suffix, e.g. "cut"), the drop line,
|
|
125
|
+
* the ratio badge, and the marker layer (when `markers`). All start hidden and `aria-hidden`.
|
|
126
|
+
*/
|
|
127
|
+
export function createOverlay(dom, host, { guide = false, markers = false } = {}) {
|
|
128
|
+
const p = dom.prefix;
|
|
129
|
+
const hiddenEl = (name) => { const node = dom.el("div", `${p}-${name}`, { "aria-hidden": "true" }); node.hidden = true; return node; };
|
|
130
|
+
const guideEl = guide ? hiddenEl(guide) : null;
|
|
131
|
+
const drop = hiddenEl("drop");
|
|
132
|
+
const ratio = hiddenEl("ratio");
|
|
133
|
+
const markerEl = markers ? dom.el("div", `${p}-markers`, { "aria-hidden": "true" }) : null;
|
|
134
|
+
host.append(...[guideEl, drop, ratio, markerEl].filter(Boolean));
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
guide: guideEl,
|
|
138
|
+
drop,
|
|
139
|
+
ratio,
|
|
140
|
+
markers: markerEl,
|
|
141
|
+
/** A guide line through the middle of `box`: across a row split it is vertical (axis "row"), else horizontal. */
|
|
142
|
+
showGuide(box, axis) {
|
|
143
|
+
if (!guideEl) return;
|
|
144
|
+
guideEl.hidden = false;
|
|
145
|
+
if (axis === "row") Object.assign(guideEl.style, { left: pct(box.x + box.w / 2), top: pct(box.y), width: "0", height: pct(box.h) });
|
|
146
|
+
else Object.assign(guideEl.style, { left: pct(box.x), top: pct(box.y + box.h / 2), width: pct(box.w), height: "0" });
|
|
147
|
+
guideEl.dataset.axis = axis;
|
|
148
|
+
},
|
|
149
|
+
hideGuide() { if (guideEl) guideEl.hidden = true; },
|
|
150
|
+
/**
|
|
151
|
+
* The drop line along `line` (`{ x, y, w, h }`): a vertical line at its left edge (between items of a row) or a horizontal one at its top.
|
|
152
|
+
* `state` is "ok", "same" (a drop that changes nothing) or "refused", with `reason` as its title.
|
|
153
|
+
*/
|
|
154
|
+
showDrop(line, { orientation, state = "ok", reason = "" }) {
|
|
155
|
+
drop.hidden = false;
|
|
156
|
+
drop.dataset.orientation = orientation;
|
|
157
|
+
drop.dataset.state = state;
|
|
158
|
+
drop.title = reason;
|
|
159
|
+
if (orientation === "vertical") Object.assign(drop.style, { left: pct(line.x), top: pct(line.y), width: "0", height: pct(line.h) });
|
|
160
|
+
else Object.assign(drop.style, { left: pct(line.x), top: pct(line.y), width: pct(line.w), height: "0" });
|
|
161
|
+
},
|
|
162
|
+
hideDrop() { drop.hidden = true; },
|
|
163
|
+
/** The ratio badge, centered on a unit point. */
|
|
164
|
+
showRatio(label, at) {
|
|
165
|
+
ratio.hidden = false;
|
|
166
|
+
ratio.textContent = label;
|
|
167
|
+
ratio.style.left = pct(at.x);
|
|
168
|
+
ratio.style.top = pct(at.y);
|
|
169
|
+
},
|
|
170
|
+
hideRatio() { ratio.hidden = true; },
|
|
171
|
+
/** Mark the hovered element in `layer` (`data-hover`), clearing the others; null clears. */
|
|
172
|
+
hover(layer, node) {
|
|
173
|
+
for (const other of layer.querySelectorAll("[data-hover]")) other.removeAttribute("data-hover");
|
|
174
|
+
node?.setAttribute("data-hover", "true");
|
|
175
|
+
},
|
|
176
|
+
/** Mark the container a drop would go into (`data-into`), clearing the others; null clears. */
|
|
177
|
+
into(layer, node) {
|
|
178
|
+
for (const other of layer.querySelectorAll("[data-into]")) other.removeAttribute("data-into");
|
|
179
|
+
node?.setAttribute("data-into", "true");
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): the selection model (an ordered list of sibling keys; the last is the primary). */
|
|
2
|
+
export interface KitSelectionAdapter<Key> {
|
|
3
|
+
equals?(a: Key, b: Key): boolean;
|
|
4
|
+
copy?(key: Key): Key;
|
|
5
|
+
exists(key: Key): boolean;
|
|
6
|
+
parent(key: Key): Key | null;
|
|
7
|
+
firstChild(key: Key): Key | null;
|
|
8
|
+
sibling(key: Key, delta: number): Key | null;
|
|
9
|
+
first(): Key | null;
|
|
10
|
+
siblings?(key: Key): Key[];
|
|
11
|
+
}
|
|
12
|
+
export interface KitSelection<Key> {
|
|
13
|
+
readonly items: Key[];
|
|
14
|
+
readonly primary: Key | null;
|
|
15
|
+
has(key: Key): boolean;
|
|
16
|
+
existing(keys: Key[]): Key[];
|
|
17
|
+
set(keys: Key[], options?: { source?: string }): boolean;
|
|
18
|
+
replace(keys: Key[]): void;
|
|
19
|
+
prune(): void;
|
|
20
|
+
snapshot(): Key[];
|
|
21
|
+
add(key: Key, options?: { source?: string }): boolean;
|
|
22
|
+
toggle(key: Key, options?: { source?: string }): boolean;
|
|
23
|
+
range(key: Key, options?: { source?: string }): boolean;
|
|
24
|
+
selectParent(options?: { source?: string }): boolean;
|
|
25
|
+
selectChild(options?: { source?: string }): boolean;
|
|
26
|
+
selectSibling(delta: number, options?: { source?: string }): boolean;
|
|
27
|
+
selectSiblings(options?: { source?: string }): boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function createSelection<Key>(adapter: KitSelectionAdapter<Key>, options?: { onChange?(event: { source?: string }): void }): KitSelection<Key>;
|
|
30
|
+
export declare function clickTarget<Node, Key>(chain: Node[], primary: Key | null, options?: { shift?: boolean; equals?(a: Key, b: Key): boolean; keyOf?(node: Node): Key; isContainer?(node: Node): boolean }): Node | null;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// CE-18, the studio kit: the selection model. An ordered list of node keys whose last entry is the primary; nothing selected means the
|
|
2
|
+
// document itself (a layout, a deck). Multi-select holds siblings only: adding a node of another parent replaces the selection. The model
|
|
3
|
+
// knows nothing about the document: a small adapter tells it whether a key exists and how keys relate. No DOM.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a selection over `adapter`:
|
|
7
|
+
* - `equals(a, b)` (default `===`), `copy(key)` (default identity);
|
|
8
|
+
* - `exists(key)`: whether the key names a selectable node now;
|
|
9
|
+
* - `parent(key)`: the parent's key, or null for a top-level node;
|
|
10
|
+
* - `firstChild(key)`: the first child's key, or null when it has none or is not a container;
|
|
11
|
+
* - `sibling(key, delta)`: the key `delta` places away in the same parent, or null past either end;
|
|
12
|
+
* - `first()`: the key to select when nothing is and the person moves to a sibling, or null;
|
|
13
|
+
* - `siblings(key)` (optional): the keys of the parent's children in order, for `selectSiblings` and `range`.
|
|
14
|
+
* `onChange({ source })` runs after each change made by a select call (not after `replace` or `prune`).
|
|
15
|
+
*/
|
|
16
|
+
export function createSelection(adapter, { onChange } = {}) {
|
|
17
|
+
const equals = adapter.equals ?? ((a, b) => a === b);
|
|
18
|
+
const copy = adapter.copy ?? ((key) => key);
|
|
19
|
+
let items = [];
|
|
20
|
+
const has = (key) => items.some((entry) => equals(entry, key));
|
|
21
|
+
const existing = (keys) => (keys ?? []).filter((key) => adapter.exists(key));
|
|
22
|
+
const sameParent = (a, b) => { const pa = adapter.parent(a), pb = adapter.parent(b); return pa === null || pb === null ? pa === pb : equals(pa, pb); };
|
|
23
|
+
|
|
24
|
+
/** Select `keys` (those that exist, in order). Returns false when the selection is already exactly that. */
|
|
25
|
+
function set(keys, { source } = {}) {
|
|
26
|
+
const next = existing(keys).map(copy);
|
|
27
|
+
if (next.length === items.length && next.every((key, index) => equals(key, items[index]))) return false;
|
|
28
|
+
items = next;
|
|
29
|
+
onChange?.({ source });
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
get items() { return items; },
|
|
35
|
+
get primary() { return items[items.length - 1] ?? null; },
|
|
36
|
+
has,
|
|
37
|
+
/** The keys of `keys` that exist now. */
|
|
38
|
+
existing,
|
|
39
|
+
set,
|
|
40
|
+
/** Replace the list as it is, without checks or a change event (history restore, a command's own result). */
|
|
41
|
+
replace(keys) { items = keys; },
|
|
42
|
+
/** Drop the keys that no longer exist, without a change event. */
|
|
43
|
+
prune() { items = existing(items); },
|
|
44
|
+
/** Copies of the keys, for a history snapshot. */
|
|
45
|
+
snapshot: () => items.map(copy),
|
|
46
|
+
/** Shift-click: add a sibling of the selection or remove a selected one; a node of another parent replaces the selection. */
|
|
47
|
+
add(key, options) {
|
|
48
|
+
if (!items.length || !existing([key]).length) return set([key], options);
|
|
49
|
+
if (!sameParent(items[0], key)) return set([key], options);
|
|
50
|
+
return set(has(key) ? items.filter((entry) => !equals(entry, key)) : [...items, key], options);
|
|
51
|
+
},
|
|
52
|
+
/** Cmd-click in a list: the same as `add`, by name. */
|
|
53
|
+
toggle(key, options) { return this.add(key, options); },
|
|
54
|
+
/** Shift-click in the layers: every sibling from the primary to `key`, in order (needs `adapter.siblings`). */
|
|
55
|
+
range(key, options) {
|
|
56
|
+
const anchor = items[items.length - 1];
|
|
57
|
+
if (!anchor || !adapter.siblings || !sameParent(anchor, key)) return set([key], options);
|
|
58
|
+
const list = adapter.siblings(key);
|
|
59
|
+
const from = list.findIndex((entry) => equals(entry, anchor)), to = list.findIndex((entry) => equals(entry, key));
|
|
60
|
+
if (from < 0 || to < 0) return set([key], options);
|
|
61
|
+
const span = from <= to ? list.slice(from, to + 1) : list.slice(to, from + 1).reverse();
|
|
62
|
+
return set(span, options);
|
|
63
|
+
},
|
|
64
|
+
/** Esc: the primary's parent, or nothing for a top-level node. */
|
|
65
|
+
selectParent(options) {
|
|
66
|
+
const key = items[items.length - 1];
|
|
67
|
+
if (key === undefined) return false;
|
|
68
|
+
const parent = adapter.parent(key);
|
|
69
|
+
return set(parent === null ? [] : [parent], options);
|
|
70
|
+
},
|
|
71
|
+
/** Enter: the primary's first child, when it is a container with children. */
|
|
72
|
+
selectChild(options) {
|
|
73
|
+
const key = items[items.length - 1];
|
|
74
|
+
if (key === undefined) return false;
|
|
75
|
+
const child = adapter.firstChild(key);
|
|
76
|
+
return child === null || child === undefined ? false : set([child], options);
|
|
77
|
+
},
|
|
78
|
+
/** Arrow keys: the primary's sibling `delta` away, or `first()` when nothing is selected. */
|
|
79
|
+
selectSibling(delta, options) {
|
|
80
|
+
const key = items[items.length - 1];
|
|
81
|
+
if (key === undefined) { const first = adapter.first(); return set(first === null || first === undefined ? [] : [first], options); }
|
|
82
|
+
const next = adapter.sibling(key, delta);
|
|
83
|
+
return next === null || next === undefined ? false : set([next], options);
|
|
84
|
+
},
|
|
85
|
+
/** Mod+A: every sibling of the primary (needs `adapter.siblings`). */
|
|
86
|
+
selectSiblings(options) {
|
|
87
|
+
const key = items[items.length - 1];
|
|
88
|
+
if (key === undefined || !adapter.siblings) return false;
|
|
89
|
+
return set(adapter.siblings(key), options);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What a click on a stack of nodes selects. `chain` is the nodes under the pointer from the outermost to the deepest. The deepest is the
|
|
96
|
+
* answer, except that when the primary is a container in the chain (and Shift is not held), the click goes one level into it.
|
|
97
|
+
*/
|
|
98
|
+
export function clickTarget(chain, primary, { shift = false, equals = (a, b) => a === b, keyOf = (node) => node.key, isContainer = () => false } = {}) {
|
|
99
|
+
if (!chain.length) return null;
|
|
100
|
+
const deepest = chain[chain.length - 1];
|
|
101
|
+
if (primary !== null && primary !== undefined && !shift) {
|
|
102
|
+
const at = chain.findIndex((node) => equals(keyOf(node), primary));
|
|
103
|
+
if (at >= 0 && isContainer(chain[at]) && at < chain.length - 1) return chain[at + 1];
|
|
104
|
+
}
|
|
105
|
+
return deepest;
|
|
106
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): the layers tree (WAI-ARIA tree pattern) and its drop places. */
|
|
2
|
+
import type { KitDom } from "./dom.js";
|
|
3
|
+
export declare function treeItem(dom: KitDom, options: { level: number; selected: boolean; expanded?: boolean; setsize?: number; posinset?: number; attributes?: Record<string, string | number>; className?: string }): HTMLLIElement;
|
|
4
|
+
export declare function rovingTabStop(items: Iterable<HTMLElement>, stop: HTMLElement | null | undefined): void;
|
|
5
|
+
export type KitTreeAction<Item> = { type: "focus"; target: Item | null } | { type: "expand" } | { type: "collapse" } | { type: "activate" } | { type: "menu" };
|
|
6
|
+
export declare function treeKeyAction<Item>(event: Pick<KeyboardEvent, "key" | "shiftKey">, item: Item, items: Item[], state?: { expandable?: boolean; expanded?: boolean; collapsible?: boolean; parent?: Item | null; firstChild?: Item | null; menu?: boolean }): KitTreeAction<Item> | null;
|
|
7
|
+
export declare function hitRow<R extends { row: Element }>(rows: R[], clientY: number): R | undefined;
|
|
8
|
+
export declare function rowDropMode(fraction: number, options?: { root?: boolean; container?: boolean }): "into" | "before" | "after";
|
|
9
|
+
export declare function showTreeDrop(mark: HTMLElement, scroller: HTMLElement, drop: { mode: "into" | "before" | "after"; state: "ok" | "same" | "refused"; reason?: string; box: DOMRect } | null | undefined): void;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// CE-18, the studio kit: the layers tree (the WAI-ARIA tree pattern). `treeItem` makes a `role="treeitem"` with its level, place, selection
|
|
2
|
+
// and expansion; `rovingTabStop` keeps one item in the tab order; `treeKeyAction` says what a key does on a focused item; `rowDropMode` and
|
|
3
|
+
// `hitRow` find where a dragged row would land; `showTreeDrop` draws that place. The host builds the rows and owns the document.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A tree item (`li`, `role="treeitem"`). `expanded` is true or false for an item with children and undefined for a leaf; `setsize` and
|
|
7
|
+
* `posinset` (1-based) are left out for the root. `attributes` are added as they are (`data-*`).
|
|
8
|
+
*/
|
|
9
|
+
export function treeItem(dom, { level, selected, expanded, setsize, posinset, attributes = {}, className = `${dom.prefix}-item` }) {
|
|
10
|
+
const li = dom.el("li", className, { role: "treeitem", "aria-level": level, ...attributes });
|
|
11
|
+
li.setAttribute("aria-selected", String(Boolean(selected)));
|
|
12
|
+
if (expanded !== undefined) li.setAttribute("aria-expanded", String(Boolean(expanded)));
|
|
13
|
+
if (setsize !== undefined) { li.setAttribute("aria-setsize", String(setsize)); li.setAttribute("aria-posinset", String(posinset)); }
|
|
14
|
+
return li;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Make `stop` the one item of `items` in the tab order. */
|
|
18
|
+
export function rovingTabStop(items, stop) {
|
|
19
|
+
for (const item of items) item.tabIndex = item === stop ? 0 : -1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a key does on the focused tree item `item` (the tree pattern). `items` are the visible items in order. Returns null, or:
|
|
24
|
+
* - `{ type: "focus", target }` (ArrowDown, ArrowUp, Home, End; ArrowRight on an expanded item goes to `firstChild`, ArrowLeft on a child to
|
|
25
|
+
* `parent`; `target` may be null when there is nowhere to go, and the key is still the tree's);
|
|
26
|
+
* - `{ type: "expand" }` (ArrowRight on a collapsed item) and `{ type: "collapse" }` (ArrowLeft on an expanded item that can collapse);
|
|
27
|
+
* - `{ type: "activate" }` (Enter or Space) and `{ type: "menu" }` (the ContextMenu key or Shift+F10, on an item that has one).
|
|
28
|
+
* `state` is `{ expandable, expanded, collapsible, parent, firstChild, menu }`.
|
|
29
|
+
*/
|
|
30
|
+
export function treeKeyAction(event, item, items, state = {}) {
|
|
31
|
+
const at = items.indexOf(item);
|
|
32
|
+
const focus = (target) => ({ type: "focus", target: target ?? null });
|
|
33
|
+
switch (event.key) {
|
|
34
|
+
case "ArrowDown": return focus(items[Math.min(items.length - 1, at + 1)]);
|
|
35
|
+
case "ArrowUp": return focus(items[Math.max(0, at - 1)]);
|
|
36
|
+
case "Home": return focus(items[0]);
|
|
37
|
+
case "End": return focus(items[items.length - 1]);
|
|
38
|
+
case "ArrowRight":
|
|
39
|
+
if (!state.expandable) return null;
|
|
40
|
+
return state.expanded ? focus(state.firstChild) : { type: "expand" };
|
|
41
|
+
case "ArrowLeft":
|
|
42
|
+
if (state.expanded && state.collapsible) return { type: "collapse" };
|
|
43
|
+
return state.parent ? focus(state.parent) : null;
|
|
44
|
+
case "Enter":
|
|
45
|
+
case " ":
|
|
46
|
+
return { type: "activate" };
|
|
47
|
+
default:
|
|
48
|
+
if ((event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) && state.menu) return { type: "menu" };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The row of `rows` (`{ row, ... }`, `row` an element) that a client y is over, or undefined. */
|
|
54
|
+
export const hitRow = (rows, clientY) => rows.find(({ row }) => { const box = row.getBoundingClientRect(); return clientY >= box.top && clientY <= box.bottom; });
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Where a drop at `fraction` of a row's height lands: "into" the row (always for the root, and in the middle 40% of a container), else
|
|
58
|
+
* "before" (the upper half) or "after".
|
|
59
|
+
*/
|
|
60
|
+
export function rowDropMode(fraction, { root = false, container = false } = {}) {
|
|
61
|
+
if (root) return "into";
|
|
62
|
+
if (container && fraction >= 0.3 && fraction <= 0.7) return "into";
|
|
63
|
+
return fraction < 0.5 ? "before" : "after";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Draw the drop place on `mark` (positioned in `scroller`): a line above or below the row's box, or the whole box for "into". `state` is
|
|
68
|
+
* "ok", "same" or "refused", with `reason` as its title. A null drop hides it.
|
|
69
|
+
*/
|
|
70
|
+
export function showTreeDrop(mark, scroller, drop) {
|
|
71
|
+
if (!drop) { mark.hidden = true; return; }
|
|
72
|
+
const parent = scroller.getBoundingClientRect();
|
|
73
|
+
mark.hidden = false;
|
|
74
|
+
mark.dataset.mode = drop.mode;
|
|
75
|
+
mark.dataset.state = drop.state;
|
|
76
|
+
mark.title = drop.reason ?? "";
|
|
77
|
+
const top = drop.mode === "after" ? drop.box.bottom : drop.box.top;
|
|
78
|
+
mark.style.top = `${top - parent.top + scroller.scrollTop}px`;
|
|
79
|
+
mark.style.left = `${drop.box.left - parent.left}px`;
|
|
80
|
+
mark.style.width = `${drop.box.width}px`;
|
|
81
|
+
mark.style.height = drop.mode === "into" ? `${drop.box.height}px` : "0px";
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): zoom (steps, smooth, fit), pan (hand, Space, two fingers) and screen-to-unit mapping. */
|
|
2
|
+
export declare const ZOOM_STEPS: readonly number[];
|
|
3
|
+
export declare function clampZoom(value: number, steps?: readonly number[]): number;
|
|
4
|
+
export declare function stepZoom(zoom: number, direction: number, steps?: readonly number[]): number;
|
|
5
|
+
export declare function toUnit(point: { clientX: number; clientY: number }, rect: { left: number; top: number; width: number; height: number }): { x: number; y: number };
|
|
6
|
+
export declare function scrollForZoom(scroll: { left: number; top: number }, anchor: { x: number; y: number }, from: number, to: number): { left: number; top: number };
|
|
7
|
+
export interface KitViewportOptions {
|
|
8
|
+
root?: HTMLElement;
|
|
9
|
+
zoomVar?: string;
|
|
10
|
+
steps?: readonly number[];
|
|
11
|
+
zoom?: number;
|
|
12
|
+
panEnabled?(): boolean;
|
|
13
|
+
wheel?: "step" | "smooth" | false;
|
|
14
|
+
pinch?: boolean;
|
|
15
|
+
fitZoom?(mode: "slide" | "width"): number;
|
|
16
|
+
onZoom?(zoom: number): void;
|
|
17
|
+
}
|
|
18
|
+
export interface KitViewport {
|
|
19
|
+
readonly zoom: number;
|
|
20
|
+
readonly panMode: boolean;
|
|
21
|
+
readonly panning: boolean;
|
|
22
|
+
setZoom(zoom: number, options?: { anchor?: { clientX: number; clientY: number } }): number;
|
|
23
|
+
step(direction: number, options?: { anchor?: { clientX: number; clientY: number } }): number;
|
|
24
|
+
fit(mode?: "slide" | "width"): number;
|
|
25
|
+
holdSpace(on: boolean): void;
|
|
26
|
+
toUnit(event: { clientX: number; clientY: number }, element: Element): { x: number; y: number };
|
|
27
|
+
destroy(): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function createViewport(viewport: HTMLElement, options?: KitViewportOptions): KitViewport;
|