@openpresentation/opf-editor 0.27.0 → 0.29.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 +23 -9
- package/dist/canvas.d.ts +13 -1
- package/dist/canvas.js +39 -9
- package/dist/chart-options-panel.js +16 -16
- package/dist/codemirror-guard.js +69 -0
- package/dist/content-controls.js +127 -74
- package/dist/data-grid.js +15 -7
- package/dist/design-controls.d.ts +13 -0
- package/dist/design-controls.js +136 -125
- package/dist/design-options.js +1 -1
- package/dist/exact-source.d.ts +30 -0
- package/dist/font-gate.js +20 -1
- package/dist/json-editor.d.ts +33 -0
- package/dist/json-editor.js +91 -4
- package/dist/json-options.d.ts +2 -1
- package/dist/json-options.js +7 -6
- 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/layout-templates.d.ts +8 -3
- package/dist/layout-templates.js +30 -16
- package/dist/numbering-panel.js +2 -3
- package/dist/react.d.ts +16 -4
- package/dist/react.js +47 -6
- package/dist/slide-adapter.d.ts +49 -0
- package/dist/slide-adapter.js +289 -0
- package/dist/slide-edits.d.ts +46 -0
- package/dist/slide-edits.js +629 -0
- package/dist/slide-manager.js +2 -1
- package/dist/slide-scene.d.ts +96 -0
- package/dist/slide-scene.js +413 -0
- package/dist/source-checker.js +45 -6
- package/dist/source-color-scheme.js +60 -0
- package/dist/source-completions.js +6 -4
- package/dist/source-pane.d.ts +103 -6
- package/dist/source-pane.js +196 -9
- package/dist/source-worker.js +8 -4
- package/dist/studio-canvas.d.ts +34 -0
- package/dist/studio-canvas.js +586 -0
- package/dist/studio-commands.d.ts +15 -0
- package/dist/studio-commands.js +155 -0
- package/dist/studio-design.d.ts +16 -0
- package/dist/studio-design.js +354 -0
- package/dist/studio-kit/commands.d.ts +38 -0
- package/dist/studio-kit/commands.js +148 -0
- package/dist/studio-kit/dom.d.ts +52 -0
- package/dist/studio-kit/dom.js +198 -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/studio-layers.d.ts +12 -0
- package/dist/studio-layers.js +218 -0
- package/dist/studio.d.ts +31 -0
- package/dist/studio.js +1608 -0
- package/dist/styles.css +1573 -123
- package/dist/thumbnail-cache.d.ts +2 -0
- package/dist/thumbnail-cache.js +10 -1
- package/dist/workspace-measure.js +6 -4
- package/dist/workspace-parts.js +20 -0
- package/dist/workspace-shell.js +25 -15
- package/dist/workspace-theme.js +3 -0
- package/dist/workspace.d.ts +114 -10
- package/dist/workspace.js +223 -49
- package/package.json +30 -19
|
@@ -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;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// CE-18, the studio kit: the viewport around a studio's frame. Zoom in steps (buttons, keys, Ctrl or Cmd with the wheel), smooth zoom (a
|
|
2
|
+
// trackpad pinch, two-finger touch), fit, and pan (the hand tool, a held Space, two fingers), plus the mapping from screen points to the
|
|
3
|
+
// frame's unit square. The zoom is a factor of the fitted size, written to a CSS variable on the root; the stylesheet sizes the stage from it.
|
|
4
|
+
|
|
5
|
+
/** The zoom steps of the Studio (STUDIO-SPEC 4.2), as factors of the fitted size. */
|
|
6
|
+
export const ZOOM_STEPS = Object.freeze([0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4]);
|
|
7
|
+
|
|
8
|
+
/** `value` kept between the first and the last step. */
|
|
9
|
+
export const clampZoom = (value, steps = ZOOM_STEPS) => Math.max(steps[0], Math.min(steps[steps.length - 1], value));
|
|
10
|
+
|
|
11
|
+
/** The next step up (`direction` 1) or down (-1) from `zoom`; a zoom between two steps goes to the nearer one in that direction. */
|
|
12
|
+
export function stepZoom(zoom, direction, steps = ZOOM_STEPS) {
|
|
13
|
+
const at = steps.findIndex((value) => value >= zoom - 1e-9);
|
|
14
|
+
const index = direction > 0 ? (steps[at] > zoom + 1e-9 ? at : at + 1) : at - 1;
|
|
15
|
+
return steps[Math.max(0, Math.min(steps.length - 1, index))];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A client point as a fraction of `rect` (a DOMRect): `{ x, y }`, 0 to 1 inside it. */
|
|
19
|
+
export const toUnit = (point, rect) => ({ x: (point.clientX - rect.left) / (rect.width || 1), y: (point.clientY - rect.top) / (rect.height || 1) });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The scroll position that keeps the content point under `anchor` (client coordinates, relative to the viewport's box) in place when the
|
|
23
|
+
* zoom changes from `from` to `to`: `{ left, top }`.
|
|
24
|
+
*/
|
|
25
|
+
export function scrollForZoom({ left, top }, anchor, from, to) {
|
|
26
|
+
const ratio = to / (from || 1);
|
|
27
|
+
return { left: (left + anchor.x) * ratio - anchor.x, top: (top + anchor.y) * ratio - anchor.y };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Attach zoom and pan to `viewport` (the scrolling element). Options:
|
|
32
|
+
* - `root`: where the zoom variable and the `data-zoomed`, `data-panning` and `data-space` states go (default the viewport);
|
|
33
|
+
* - `zoomVar`: the CSS variable (default `--opf-kit-zoom`); `steps` (default `ZOOM_STEPS`); `zoom` (the first zoom, default 1);
|
|
34
|
+
* - `panEnabled()`: whether a drag pans now (the hand tool); a held Space pans as well (`holdSpace`);
|
|
35
|
+
* - `wheel`: "step" (Ctrl or Cmd with the wheel steps the zoom, the default), "smooth" (it zooms continuously around the pointer) or false;
|
|
36
|
+
* - `pinch`: two touch pointers zoom around their midpoint and pan (default false);
|
|
37
|
+
* - `fitZoom(mode)`: the zoom of `fit(mode)` ("slide" or "width"), default 1 (the zoom is a factor of the fitted size);
|
|
38
|
+
* - `onZoom(zoom)`: after each change.
|
|
39
|
+
* Returns `{ zoom, panMode, setZoom, step, fit, holdSpace, toUnit, destroy }`.
|
|
40
|
+
*/
|
|
41
|
+
export function createViewport(viewport, options = {}) {
|
|
42
|
+
const root = options.root ?? viewport;
|
|
43
|
+
const steps = options.steps ?? ZOOM_STEPS;
|
|
44
|
+
const zoomVar = options.zoomVar ?? "--opf-kit-zoom";
|
|
45
|
+
const wheelMode = options.wheel === undefined ? "step" : options.wheel;
|
|
46
|
+
let zoom = 1;
|
|
47
|
+
let spaceHeld = false;
|
|
48
|
+
let pan = null;
|
|
49
|
+
const touches = new Map();
|
|
50
|
+
let pinch = null;
|
|
51
|
+
const panMode = () => Boolean(options.panEnabled?.()) || spaceHeld;
|
|
52
|
+
|
|
53
|
+
function setZoom(value, { anchor } = {}) {
|
|
54
|
+
const next = clampZoom(value, steps);
|
|
55
|
+
const before = zoom;
|
|
56
|
+
zoom = next;
|
|
57
|
+
root.style.setProperty(zoomVar, String(zoom));
|
|
58
|
+
root.dataset.zoomed = String(zoom !== 1);
|
|
59
|
+
if (anchor && before !== next) {
|
|
60
|
+
const box = viewport.getBoundingClientRect();
|
|
61
|
+
const scroll = scrollForZoom({ left: viewport.scrollLeft, top: viewport.scrollTop }, { x: anchor.clientX - box.left, y: anchor.clientY - box.top }, before, next);
|
|
62
|
+
viewport.scrollLeft = scroll.left;
|
|
63
|
+
viewport.scrollTop = scroll.top;
|
|
64
|
+
}
|
|
65
|
+
options.onZoom?.(zoom);
|
|
66
|
+
return zoom;
|
|
67
|
+
}
|
|
68
|
+
const step = (direction, opts) => setZoom(stepZoom(zoom, direction, steps), opts);
|
|
69
|
+
const fit = (mode = "slide") => setZoom(options.fitZoom ? options.fitZoom(mode) : 1);
|
|
70
|
+
|
|
71
|
+
// Pan: a drag with the hand tool or Space; the capture phase keeps the drag from reaching the frame below.
|
|
72
|
+
const onDown = (event) => {
|
|
73
|
+
if (options.pinch && event.pointerType === "touch") {
|
|
74
|
+
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
75
|
+
if (touches.size === 2) { startPinch(); event.preventDefault(); event.stopPropagation(); return; }
|
|
76
|
+
}
|
|
77
|
+
if (!panMode() || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
event.stopPropagation();
|
|
80
|
+
pan = { x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop };
|
|
81
|
+
root.dataset.panning = "true";
|
|
82
|
+
viewport.setPointerCapture?.(event.pointerId);
|
|
83
|
+
};
|
|
84
|
+
const onMove = (event) => {
|
|
85
|
+
if (touches.has(event.pointerId)) {
|
|
86
|
+
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
87
|
+
if (pinch) { movePinch(); return; }
|
|
88
|
+
}
|
|
89
|
+
if (!pan) return;
|
|
90
|
+
viewport.scrollLeft = pan.left - (event.clientX - pan.x);
|
|
91
|
+
viewport.scrollTop = pan.top - (event.clientY - pan.y);
|
|
92
|
+
};
|
|
93
|
+
const endPan = (event) => {
|
|
94
|
+
if (event && touches.delete(event.pointerId) && pinch && touches.size < 2) { pinch = null; delete root.dataset.panning; }
|
|
95
|
+
pan = null;
|
|
96
|
+
delete root.dataset.panning;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Two fingers: the distance between them scales the zoom from where it was, and the content point first under their midpoint stays
|
|
100
|
+
// under it (so moving both fingers pans).
|
|
101
|
+
const pair = () => { const [a, b] = [...touches.values()]; return { distance: Math.hypot(b.x - a.x, b.y - a.y) || 1, mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } }; };
|
|
102
|
+
function startPinch() {
|
|
103
|
+
pan = null;
|
|
104
|
+
const { distance, mid } = pair();
|
|
105
|
+
const box = viewport.getBoundingClientRect();
|
|
106
|
+
pinch = { distance, zoom, point: { x: (viewport.scrollLeft + mid.x - box.left) / zoom, y: (viewport.scrollTop + mid.y - box.top) / zoom } };
|
|
107
|
+
root.dataset.panning = "true";
|
|
108
|
+
}
|
|
109
|
+
function movePinch() {
|
|
110
|
+
const { distance, mid } = pair();
|
|
111
|
+
const next = setZoom(pinch.zoom * (distance / pinch.distance));
|
|
112
|
+
const box = viewport.getBoundingClientRect();
|
|
113
|
+
viewport.scrollLeft = pinch.point.x * next - (mid.x - box.left);
|
|
114
|
+
viewport.scrollTop = pinch.point.y * next - (mid.y - box.top);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const onWheel = (event) => {
|
|
118
|
+
if (!wheelMode || !(event.ctrlKey || event.metaKey)) return;
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
if (wheelMode === "smooth") setZoom(zoom * Math.exp(-event.deltaY * 0.01), { anchor: event });
|
|
121
|
+
else step(event.deltaY < 0 ? 1 : -1);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
viewport.addEventListener("pointerdown", onDown, true);
|
|
125
|
+
viewport.addEventListener("pointermove", onMove);
|
|
126
|
+
viewport.addEventListener("pointerup", endPan);
|
|
127
|
+
viewport.addEventListener("pointercancel", endPan);
|
|
128
|
+
viewport.addEventListener("wheel", onWheel, { passive: false });
|
|
129
|
+
setZoom(options.zoom ?? 1);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
get zoom() { return zoom; },
|
|
133
|
+
get panMode() { return panMode(); },
|
|
134
|
+
get panning() { return pan !== null || pinch !== null; },
|
|
135
|
+
setZoom,
|
|
136
|
+
step,
|
|
137
|
+
fit,
|
|
138
|
+
/** Space held (true) or released (false): a drag pans while it is held. */
|
|
139
|
+
holdSpace(on) {
|
|
140
|
+
spaceHeld = Boolean(on);
|
|
141
|
+
if (spaceHeld) root.dataset.space = "true"; else delete root.dataset.space;
|
|
142
|
+
},
|
|
143
|
+
/** A pointer event's position as a fraction of `element`'s box. */
|
|
144
|
+
toUnit: (event, element) => toUnit(event, element.getBoundingClientRect()),
|
|
145
|
+
destroy() {
|
|
146
|
+
viewport.removeEventListener("pointerdown", onDown, true);
|
|
147
|
+
viewport.removeEventListener("pointermove", onMove);
|
|
148
|
+
viewport.removeEventListener("pointerup", endPan);
|
|
149
|
+
viewport.removeEventListener("pointercancel", endPan);
|
|
150
|
+
viewport.removeEventListener("wheel", onWheel);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** CE-20 (internal): the Studio's layers tree over the current slide's scene. */
|
|
2
|
+
import type { KitDom } from "./studio-kit/dom.js";
|
|
3
|
+
import type { SlideAdapter } from "./slide-adapter.js";
|
|
4
|
+
|
|
5
|
+
export interface StudioLayers {
|
|
6
|
+
element: HTMLElement;
|
|
7
|
+
render(): void;
|
|
8
|
+
paintSelection(options?: { focus?: boolean }): void;
|
|
9
|
+
focus(): void;
|
|
10
|
+
destroy(): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function createStudioLayers(container: HTMLElement, options: { dom: KitDom; adapter: SlideAdapter; studio: any }): StudioLayers;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// CE-20: the Studio's layers panel, the current slide's scene as a tree (the WAI-ARIA tree pattern from the kit: `role="tree"`, a roving tab
|
|
2
|
+
// stop, `aria-level`, `aria-setsize`, `aria-posinset`, `aria-selected`, `aria-expanded`). A click selects, Shift+click selects a range of
|
|
3
|
+
// siblings, Cmd or Ctrl+click toggles one; the arrow keys move and expand or collapse; Enter goes to the canvas with that node selected; F2 edits
|
|
4
|
+
// a text node; a drag reorders or moves into a container with the canvas's trial and reason. One selection for every surface: the Studio
|
|
5
|
+
// owns it, this only draws it and asks for changes.
|
|
6
|
+
import { hitRow, rovingTabStop, rowDropMode, showTreeDrop, treeItem, treeKeyAction } from "./studio-kit/tree.js";
|
|
7
|
+
import { isTypingTarget } from "./studio-kit/dom.js";
|
|
8
|
+
|
|
9
|
+
const ICON_OF = { slide: "Square", heading: "Heading", region: "Columns2", group: "Group", implicit: "AlignLeft", part: "GitCommitHorizontal" };
|
|
10
|
+
const TYPE_ICON = { text: "AlignLeft", list: "List", image: "Image", video: "Video", chart: "ChartColumn", table: "Table", code: "Code", metric: "Hash", quote: "Quote", timeline: "CalendarRange" };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mount the layers tree in `container`. `adapter` is the slide adapter, `studio` the controller. Returns `{ element, render, paintSelection,
|
|
14
|
+
* focus, destroy }`.
|
|
15
|
+
*/
|
|
16
|
+
export function createStudioLayers(container, { dom, adapter, studio }) {
|
|
17
|
+
const doc = container.ownerDocument;
|
|
18
|
+
const { el } = dom;
|
|
19
|
+
const p = dom.prefix;
|
|
20
|
+
const scroller = el("div", `${p}-layers-scroll`);
|
|
21
|
+
const tree = el("ul", `${p}-tree`, { role: "tree", "aria-label": "Layers", "aria-multiselectable": "true" });
|
|
22
|
+
const mark = el("div", `${p}-tree-drop`, { "aria-hidden": "true" });
|
|
23
|
+
mark.hidden = true;
|
|
24
|
+
scroller.append(tree, mark);
|
|
25
|
+
container.append(scroller);
|
|
26
|
+
const collapsed = new Set();
|
|
27
|
+
let rows = [];
|
|
28
|
+
let focusKey = null;
|
|
29
|
+
let drag = null;
|
|
30
|
+
let lastScene = null;
|
|
31
|
+
|
|
32
|
+
const scene = () => adapter.scene();
|
|
33
|
+
const visibleChildren = (s, key) => s.children(key).filter((node) => node.kind !== "part");
|
|
34
|
+
|
|
35
|
+
function render() {
|
|
36
|
+
const s = scene();
|
|
37
|
+
lastScene = s;
|
|
38
|
+
const hadFocus = tree.contains(doc.activeElement);
|
|
39
|
+
const selected = new Set(studio.selectedKeys());
|
|
40
|
+
const items = [];
|
|
41
|
+
rows = [];
|
|
42
|
+
const add = (node, level, setsize, posinset) => {
|
|
43
|
+
const children = visibleChildren(s, node.key);
|
|
44
|
+
const expandable = children.length > 0;
|
|
45
|
+
const expanded = expandable && !collapsed.has(node.key);
|
|
46
|
+
const li = treeItem(dom, { level, selected: selected.has(node.key), expanded: expandable ? expanded : undefined, setsize: level > 1 ? setsize : undefined, posinset: level > 1 ? posinset : undefined, attributes: { "data-key": node.key, "data-kind": node.kind }, className: `${p}-row` });
|
|
47
|
+
const label = el("span", `${p}-row-label`);
|
|
48
|
+
label.style.setProperty("--opf-studio-level", String(level - 1));
|
|
49
|
+
if (expandable) {
|
|
50
|
+
const caret = el("span", `${p}-caret`, { "aria-hidden": "true" });
|
|
51
|
+
caret.append(dom.icon(expanded ? "ChevronDown" : "ChevronRight", 14));
|
|
52
|
+
caret.addEventListener("click", (event) => { event.stopPropagation(); toggle(node.key); });
|
|
53
|
+
label.append(caret);
|
|
54
|
+
} else label.append(el("span", `${p}-caret`, { "aria-hidden": "true" }));
|
|
55
|
+
label.append(dom.icon(node.kind === "block" || node.kind === "implicit" ? TYPE_ICON[node.type] ?? "Square" : ICON_OF[node.kind] ?? "Square", 14));
|
|
56
|
+
label.append(el("span", `${p}-row-text`, { text: node.kind === "slide" ? studio.slideLabel(node) : node.label }));
|
|
57
|
+
if (node.findings.length) label.append(el("span", `${p}-row-badge`, { text: String(node.findings.length), title: node.findings.map((finding) => finding.message).join("\n"), "aria-label": `${node.findings.length} finding${node.findings.length === 1 ? "" : "s"}` }));
|
|
58
|
+
if (node.hidden) { const hidden = el("span", `${p}-row-hidden`, { title: "Hidden slide" }); hidden.append(dom.icon("EyeOff", 14)); label.append(hidden); }
|
|
59
|
+
li.append(label);
|
|
60
|
+
items.push(li);
|
|
61
|
+
rows.push({ row: li, key: node.key, node, level, expandable, expanded });
|
|
62
|
+
if (expanded) {
|
|
63
|
+
const group = el("ul", `${p}-tree-group`, { role: "group" });
|
|
64
|
+
li.append(group);
|
|
65
|
+
const parentItems = items.length;
|
|
66
|
+
children.forEach((child, index) => add(child, level + 1, children.length, index + 1));
|
|
67
|
+
group.append(...items.splice(parentItems));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const slide = s.node(s.slideKey);
|
|
71
|
+
add(slide, 1, 1, 1);
|
|
72
|
+
// The Background pseudo-row: it selects the slide and shows the Design panel's background section.
|
|
73
|
+
const background = treeItem(dom, { level: 2, selected: false, setsize: visibleChildren(s, s.slideKey).length + 1, posinset: visibleChildren(s, s.slideKey).length + 1, attributes: { "data-key": "#background", "data-kind": "background" }, className: `${p}-row` });
|
|
74
|
+
const backgroundLabel = el("span", `${p}-row-label`);
|
|
75
|
+
backgroundLabel.style.setProperty("--opf-studio-level", "1");
|
|
76
|
+
backgroundLabel.append(el("span", `${p}-caret`, { "aria-hidden": "true" }), dom.icon("Image", 14), el("span", `${p}-row-text`, { text: "Background" }));
|
|
77
|
+
background.append(backgroundLabel);
|
|
78
|
+
rows.push({ row: background, key: "#background", node: null, level: 2, expandable: false, expanded: false });
|
|
79
|
+
const slideGroup = items[0]?.querySelector(":scope > ul");
|
|
80
|
+
if (slideGroup) slideGroup.append(background);
|
|
81
|
+
else { const group = el("ul", `${p}-tree-group`, { role: "group" }); group.append(background); items[0]?.append(group); }
|
|
82
|
+
tree.replaceChildren(...items);
|
|
83
|
+
paintSelection({ focus: hadFocus });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Mark the selection and the tab stop without rebuilding (`focus`: move focus to the stop). */
|
|
87
|
+
function paintSelection({ focus = false } = {}) {
|
|
88
|
+
if (scene() !== lastScene) { render(); return; }
|
|
89
|
+
const selected = new Set(studio.selectedKeys());
|
|
90
|
+
const primary = studio.primaryKey();
|
|
91
|
+
for (const entry of rows) entry.row.setAttribute("aria-selected", String(entry.key !== "#background" && selected.has(entry.key)));
|
|
92
|
+
const stop = (focus ? null : rows.find((entry) => entry.key === (focusKey && tree.contains(doc.activeElement) ? focusKey : primary))) ?? rows.find((entry) => entry.key === primary) ?? rows[0];
|
|
93
|
+
if (focus && stop) focusKey = stop.key;
|
|
94
|
+
rovingTabStop(rows.map((entry) => entry.row), stop?.row);
|
|
95
|
+
if (focus && stop) stop.row.focus({ preventScroll: false });
|
|
96
|
+
else if (stop && !tree.contains(doc.activeElement)) stop.row.scrollIntoView?.({ block: "nearest" });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toggle(key) {
|
|
100
|
+
if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key);
|
|
101
|
+
focusKey = key;
|
|
102
|
+
render();
|
|
103
|
+
rows.find((entry) => entry.key === key)?.row.focus();
|
|
104
|
+
}
|
|
105
|
+
const rowOf = (target) => rows.find((entry) => entry.row === target.closest?.(`.${p}-row`));
|
|
106
|
+
|
|
107
|
+
function activate(entry, event) {
|
|
108
|
+
if (entry.key === "#background") { studio.select([scene().slideKey], { source: "layers" }); studio.showDesignSection("background"); return; }
|
|
109
|
+
if (event.shiftKey) studio.selectRange(entry.key, { source: "layers" });
|
|
110
|
+
else if (event.metaKey || event.ctrlKey) studio.selectAdd(entry.key, { source: "layers" });
|
|
111
|
+
else studio.select([entry.key], { source: "layers" });
|
|
112
|
+
}
|
|
113
|
+
tree.addEventListener("click", (event) => {
|
|
114
|
+
const entry = rowOf(event.target);
|
|
115
|
+
if (!entry || drag?.moved) return;
|
|
116
|
+
focusKey = entry.key;
|
|
117
|
+
activate(entry, event);
|
|
118
|
+
entry.row.focus({ preventScroll: true });
|
|
119
|
+
});
|
|
120
|
+
tree.addEventListener("focusin", (event) => { const entry = rowOf(event.target); if (entry) focusKey = entry.key; });
|
|
121
|
+
tree.addEventListener("keydown", (event) => {
|
|
122
|
+
if (isTypingTarget(event.target)) return;
|
|
123
|
+
const entry = rowOf(event.target);
|
|
124
|
+
if (!entry) return;
|
|
125
|
+
const visible = rows.map((item) => item.row);
|
|
126
|
+
const parentRow = entry.node?.parent ? rows.find((item) => item.key === entry.node.parent)?.row : entry.key === "#background" ? rows[0]?.row : null;
|
|
127
|
+
const firstChild = entry.expanded ? rows[rows.indexOf(entry) + 1]?.row : null;
|
|
128
|
+
if (event.key === "F2" && entry.node) { event.preventDefault(); studio.select([entry.key], { source: "layers" }); studio.beginTextEdit(entry.key); return; }
|
|
129
|
+
if (event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
event.stopPropagation();
|
|
132
|
+
activate(entry, {});
|
|
133
|
+
studio.focusCanvas();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if ((event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) && entry.node) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
if (!studio.selectedKeys().includes(entry.key)) studio.select([entry.key], { source: "layers" });
|
|
139
|
+
const box = entry.row.getBoundingClientRect();
|
|
140
|
+
studio.openContextMenu({ x: box.left + 24, y: box.bottom }, { returnFocus: entry.row });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const action = treeKeyAction(event, entry.row, visible, { expandable: entry.expandable, expanded: entry.expanded, collapsible: entry.expandable, parent: parentRow, firstChild, menu: false });
|
|
144
|
+
if (!action || action.type === "activate" || action.type === "menu") {
|
|
145
|
+
if (event.key === " ") { event.preventDefault(); activate(entry, event); }
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
if (action.type === "expand" || action.type === "collapse") { toggle(entry.key); return; }
|
|
151
|
+
const target = rows.find((item) => item.row === action.target);
|
|
152
|
+
if (!target) return;
|
|
153
|
+
focusKey = target.key;
|
|
154
|
+
rovingTabStop(visible, target.row);
|
|
155
|
+
target.row.focus();
|
|
156
|
+
if (target.key !== "#background" && !event.shiftKey) studio.select([target.key], { source: "layers" });
|
|
157
|
+
else if (event.shiftKey && target.key !== "#background") studio.selectRange(target.key, { source: "layers" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// --- drag to reorder or move into a container -------------------------------------------------------------------------------------------
|
|
161
|
+
tree.addEventListener("pointerdown", (event) => {
|
|
162
|
+
const entry = rowOf(event.target);
|
|
163
|
+
if (!entry?.node?.can?.move || studio.readOnly || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
164
|
+
drag = { entry, x: event.clientX, y: event.clientY, id: event.pointerId, moved: false, drop: null };
|
|
165
|
+
});
|
|
166
|
+
const dropFor = (clientY) => {
|
|
167
|
+
const target = hitRow(rows.filter((item) => item.node), clientY);
|
|
168
|
+
if (!target) return null;
|
|
169
|
+
const box = target.row.querySelector(`.${p}-row-label`).getBoundingClientRect();
|
|
170
|
+
const node = target.node;
|
|
171
|
+
const container = node.kind === "slide" || node.kind === "group" || node.kind === "region";
|
|
172
|
+
const mode = rowDropMode((clientY - box.top) / Math.max(1, box.height), { root: node.kind === "slide", container });
|
|
173
|
+
const keys = studio.selectedKeys().includes(drag.entry.key) ? studio.selectedKeys() : [drag.entry.key];
|
|
174
|
+
let targetKey, index;
|
|
175
|
+
if (mode === "into") { targetKey = node.key; index = scene().contentChildren(node.key).length; }
|
|
176
|
+
else {
|
|
177
|
+
if (!node.parent) return null;
|
|
178
|
+
targetKey = node.parent;
|
|
179
|
+
const list = scene().contentChildren(node.parent);
|
|
180
|
+
index = list.findIndex((child) => child.key === node.key) + (mode === "after" ? 1 : 0);
|
|
181
|
+
if (index < 0) return null;
|
|
182
|
+
}
|
|
183
|
+
const trial = adapter.trialMove(keys, targetKey, index);
|
|
184
|
+
return { keys, target: targetKey, index, mode, box, ...trial };
|
|
185
|
+
};
|
|
186
|
+
tree.addEventListener("pointermove", (event) => {
|
|
187
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
188
|
+
if (!drag.moved && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 4) return;
|
|
189
|
+
if (!drag.moved) { drag.moved = true; try { tree.setPointerCapture?.(event.pointerId); } catch { /* a pointer that cannot be captured (a synthetic one) */ } if (!studio.selectedKeys().includes(drag.entry.key)) studio.select([drag.entry.key], { source: "layers" }); }
|
|
190
|
+
drag.drop = dropFor(event.clientY);
|
|
191
|
+
showTreeDrop(mark, scroller, drag.drop ? { mode: drag.drop.mode, state: drag.drop.ok ? (drag.drop.noop ? "same" : "ok") : "refused", reason: drag.drop.reason, box: drag.drop.box } : null);
|
|
192
|
+
if (drag.drop && !drag.drop.ok) studio.status(drag.drop.reason);
|
|
193
|
+
});
|
|
194
|
+
const endDrag = (commit) => {
|
|
195
|
+
const session = drag;
|
|
196
|
+
drag = null;
|
|
197
|
+
showTreeDrop(mark, scroller, null);
|
|
198
|
+
if (!session?.moved || !commit) return;
|
|
199
|
+
// The click that follows a drag must not select the row under the pointer.
|
|
200
|
+
drag = { moved: true };
|
|
201
|
+
globalThis.setTimeout(() => { if (drag?.moved && !drag.entry) drag = null; }, 0);
|
|
202
|
+
const found = session.drop;
|
|
203
|
+
if (!found) return;
|
|
204
|
+
if (!found.ok) { studio.announce(found.reason); return; }
|
|
205
|
+
if (!found.noop) studio.move(found.keys, found.target, found.index);
|
|
206
|
+
};
|
|
207
|
+
tree.addEventListener("pointerup", () => endDrag(true));
|
|
208
|
+
tree.addEventListener("pointercancel", () => endDrag(false));
|
|
209
|
+
tree.addEventListener("keydown", (event) => { if (event.key === "Escape" && drag?.moved) { event.preventDefault(); event.stopPropagation(); endDrag(false); studio.announce("Move cancelled."); } }, true);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
element: scroller,
|
|
213
|
+
render,
|
|
214
|
+
paintSelection,
|
|
215
|
+
focus() { (rows.find((entry) => entry.row.tabIndex === 0) ?? rows[0])?.row.focus(); },
|
|
216
|
+
destroy() { scroller.remove(); },
|
|
217
|
+
};
|
|
218
|
+
}
|
package/dist/studio.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CE-20 (internal): the Studio's shell and controller. Hosts use `createOpfEditor(el, { layout: "studio" })` from
|
|
3
|
+
* `@openpresentation/opf-editor/workspace`; the workspace calls these.
|
|
4
|
+
*/
|
|
5
|
+
import type { KitDom } from "./studio-kit/dom.js";
|
|
6
|
+
import type { OpfCommand, OpfCommandInfo, OpfEditorSlots, OpfStudioOptions, OpfStudioSelectionItem } from "./workspace.js";
|
|
7
|
+
import type { SlideAdapter } from "./slide-adapter.js";
|
|
8
|
+
|
|
9
|
+
export interface StudioShell {
|
|
10
|
+
dom: KitDom;
|
|
11
|
+
[element: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
export declare function buildStudioShell(root: HTMLElement, options: { parts: Record<string, boolean>; layout: "studio" | "playground"; labels?: Record<string, string>; studio?: OpfStudioOptions; slots?: OpfEditorSlots }): { slots: OpfEditorSlots & Record<string, HTMLElement>; controls: { undo: HTMLButtonElement; redo: HTMLButtonElement }; shell: StudioShell };
|
|
14
|
+
export interface Studio {
|
|
15
|
+
readonly layout: "studio" | "playground";
|
|
16
|
+
readonly selection: readonly OpfStudioSelectionItem[];
|
|
17
|
+
readonly zoom: number;
|
|
18
|
+
readonly adapter: SlideAdapter;
|
|
19
|
+
readonly commands: { list(): OpfCommandInfo[]; run(id: string): boolean; register(command: OpfCommand): () => void };
|
|
20
|
+
setLayout(layout: "studio" | "playground"): boolean;
|
|
21
|
+
select(keys: string[], options?: { source?: string }): void;
|
|
22
|
+
selectTargets(keysOrPaths: string | string[]): boolean;
|
|
23
|
+
revealPath(path: string): boolean;
|
|
24
|
+
setZoom(zoom: number): number;
|
|
25
|
+
fit(mode?: "slide" | "width"): number;
|
|
26
|
+
openLayout(layoutId?: string, options?: { slide?: number }): boolean;
|
|
27
|
+
showTab(id: string, options?: { focus?: boolean }): boolean;
|
|
28
|
+
refresh(): void;
|
|
29
|
+
destroy(): void;
|
|
30
|
+
}
|
|
31
|
+
export declare function createStudio(context: Record<string, any>): Studio;
|