@openpresentation/opf-editor 0.28.0 → 0.29.1
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 +20 -6
- 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 +134 -124
- package/dist/exact-source.d.ts +30 -0
- package/dist/font-gate.js +20 -1
- package/dist/json-editor.js +8 -2
- package/dist/json-options.d.ts +2 -1
- package/dist/json-options.js +7 -6
- 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 +12 -4
- package/dist/react.js +39 -5
- 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-completions.js +6 -4
- package/dist/source-pane.d.ts +18 -6
- package/dist/source-pane.js +45 -0
- 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/dom.d.ts +16 -0
- package/dist/studio-kit/dom.js +42 -6
- 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 +1546 -117
- 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 +97 -9
- package/dist/workspace.js +325 -59
- package/package.json +29 -18
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// CE-20: the Studio's canvas. The slide is drawn by the existing canvas (`createCanvasEditor`, with passive targets: it keeps its text editing,
|
|
2
|
+
// crop and properties, while selection is drawn here) inside the kit viewport (zoom steps, fit, fit width, pinch, Space and the hand tool).
|
|
3
|
+
// Over it, the kit overlay: the hover outline, the outline of the selection's container, one box per selected node with a label chip above the
|
|
4
|
+
// primary, findings badges, the dividers of the selected container, the drop line and "into" highlight while dragging, the ratio badge while
|
|
5
|
+
// resizing, and the (empty) marker layer. Every box of a selectable node is a real focusable element (`role="button"`, `aria-pressed`), so
|
|
6
|
+
// the slide works by keyboard and a screen reader hears what is selected; the SVG stays `aria-hidden`.
|
|
7
|
+
//
|
|
8
|
+
// Boxes come from the slide adapter's scene (CE-19) in slide units, so the overlay never measures the SVG. Slides are auto-layout (D2): a drag
|
|
9
|
+
// lands in a container at an index (the drop line), never at coordinates, and a divider changes track weights, never a box.
|
|
10
|
+
import { createViewport, stepZoom } from "./studio-kit/viewport.js";
|
|
11
|
+
import { createKeyedLayer, createOverlay, dividerKeyAction, paintBadge, paintDivider, paintSelectable, ratioLabel, setBox } from "./studio-kit/overlay.js";
|
|
12
|
+
import { createDragController, dragGhost, trackPointer } from "./studio-kit/drag.js";
|
|
13
|
+
import { isTypingTarget, pct } from "./studio-kit/dom.js";
|
|
14
|
+
import { drawnTracks } from "./slide-edits.js";
|
|
15
|
+
|
|
16
|
+
const STEPS = Object.freeze([0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4]);
|
|
17
|
+
const PAD = 24;
|
|
18
|
+
const CONTAINER_KINDS = new Set(["slide", "region", "group"]);
|
|
19
|
+
const TEXT_TYPES = new Set(["text", "list", "quote", "metric", "code", "timeline", "title", "subtitle", "tag"]);
|
|
20
|
+
const KIND_WORDS = { heading: "heading", region: "region", group: "group", block: "block", part: "part", implicit: "content written on the slide", slide: "slide" };
|
|
21
|
+
const DIRECTION_WORDS = { row: "row", column: "column" };
|
|
22
|
+
|
|
23
|
+
/** The dotted OPF path of a JSON Pointer (`/slides/0/blocks/1` → `slides.0.blocks.1`). */
|
|
24
|
+
export const dottedPath = (pointer) => String(pointer ?? "").split("/").slice(1).map((token) => token.replace(/~1/g, "/").replace(/~0/g, "~")).join(".");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mount the canvas behavior over the Studio's elements. `elements` are `{ root, viewport, stage, frame, overlay }` (built by the shell);
|
|
28
|
+
* `canvas` is the workspace's canvas editor; `adapter` the slide adapter; `studio` the controller (selection, commands, announcements).
|
|
29
|
+
* Returns `{ paint, paintHover, zoom, setZoom, step, fit, setTool, focusPrimary, boxOf, textTargetOf, beginInsertDrag, setEnabled, destroy }`.
|
|
30
|
+
*/
|
|
31
|
+
export function createStudioCanvas({ dom, elements, canvas, adapter, studio }) {
|
|
32
|
+
const { root, viewport, stage, frame, overlay } = elements;
|
|
33
|
+
const doc = root.ownerDocument, win = doc.defaultView;
|
|
34
|
+
const { el } = dom;
|
|
35
|
+
const p = dom.prefix;
|
|
36
|
+
const layer = el("div", `${p}-layer`);
|
|
37
|
+
const dividers = el("div", `${p}-dividers`);
|
|
38
|
+
const chip = el("div", `${p}-chip`, { "aria-hidden": "true" });
|
|
39
|
+
chip.hidden = true;
|
|
40
|
+
const parentOutline = el("div", `${p}-parent`, { "aria-hidden": "true" });
|
|
41
|
+
parentOutline.hidden = true;
|
|
42
|
+
const hoverBox = el("div", `${p}-hover`, { "aria-hidden": "true" });
|
|
43
|
+
hoverBox.hidden = true;
|
|
44
|
+
const empty = el("div", `${p}-empty`);
|
|
45
|
+
empty.hidden = true;
|
|
46
|
+
// A layout region that holds nothing shows a dashed placeholder with the kinds it accepts and a + that opens Insert for those kinds.
|
|
47
|
+
const placeholders = el("div", `${p}-placeholders`);
|
|
48
|
+
overlay.append(parentOutline, hoverBox, placeholders, layer, dividers, chip, empty);
|
|
49
|
+
const marks = createOverlay(dom, overlay, { markers: true });
|
|
50
|
+
const keyed = createKeyedLayer(layer);
|
|
51
|
+
const keyedDividers = createKeyedLayer(dividers);
|
|
52
|
+
const keyedPlaceholders = createKeyedLayer(placeholders);
|
|
53
|
+
let tool = "select";
|
|
54
|
+
let enabled = true;
|
|
55
|
+
let hover = null;
|
|
56
|
+
let resize = null;
|
|
57
|
+
let longPress = null;
|
|
58
|
+
let insertDrag = null;
|
|
59
|
+
let destroyed = false;
|
|
60
|
+
|
|
61
|
+
// --- the viewport (zoom and pan) -------------------------------------------------------------------------------------------------------
|
|
62
|
+
const fitted = () => {
|
|
63
|
+
const width = viewport.clientWidth - 2 * PAD, height = viewport.clientHeight - 2 * PAD;
|
|
64
|
+
const aspect = Number.parseFloat(root.style.getPropertyValue("--opf-studio-aspect")) || 16 / 9;
|
|
65
|
+
return { width: Math.max(1, Math.min(width, height * aspect)), height: Math.max(1, Math.min(height, width / aspect)), room: Math.max(1, width) };
|
|
66
|
+
};
|
|
67
|
+
const view = createViewport(viewport, {
|
|
68
|
+
root, steps: STEPS, zoomVar: "--opf-studio-zoom", wheel: false, pinch: true,
|
|
69
|
+
panEnabled: () => tool === "hand",
|
|
70
|
+
fitZoom: (mode) => (mode === "width" ? Math.max(STEPS[0], Math.min(STEPS.at(-1), fitted().room / fitted().width)) : 1),
|
|
71
|
+
onZoom: (value) => studio.onZoom?.(value),
|
|
72
|
+
});
|
|
73
|
+
// Ctrl or Cmd with a mouse wheel steps the zoom; a trackpad pinch (small ctrlKey wheel deltas) zooms smoothly around the pointer.
|
|
74
|
+
const onWheel = (event) => {
|
|
75
|
+
if (!(event.ctrlKey || event.metaKey) || !enabled) return;
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
if (Math.abs(event.deltaY) < 40 && event.deltaMode === 0) view.setZoom(view.zoom * Math.exp(-event.deltaY * 0.01), { anchor: event });
|
|
78
|
+
else view.setZoom(stepZoom(view.zoom, event.deltaY < 0 ? 1 : -1, STEPS), { anchor: event });
|
|
79
|
+
};
|
|
80
|
+
viewport.addEventListener("wheel", onWheel, { passive: false });
|
|
81
|
+
|
|
82
|
+
// --- geometry ----------------------------------------------------------------------------------------------------------------------------
|
|
83
|
+
const unit = (event) => view.toUnit(event, frame);
|
|
84
|
+
const scene = () => adapter.scene();
|
|
85
|
+
const boxEl = (key) => keyed.get(`n:${key}`);
|
|
86
|
+
const isSelected = (key) => studio.selectedKeys().includes(key);
|
|
87
|
+
|
|
88
|
+
// What a screen reader hears for a node: its label, kind and place ("Text: Start with…, block, 2 of 3 in a row"), and its findings.
|
|
89
|
+
function describe(node) {
|
|
90
|
+
if (node.kind === "slide") return `${node.label}${node.hidden ? ", hidden" : ""}`;
|
|
91
|
+
const s = scene();
|
|
92
|
+
const parent = s.node(node.parent);
|
|
93
|
+
const list = parent ? s.children(parent.key).filter((child) => child.kind !== "part") : [];
|
|
94
|
+
const place = parent ? `, ${list.findIndex((child) => child.key === node.key) + 1} of ${list.length} in ${parent.kind === "slide" ? "the slide" : `a ${DIRECTION_WORDS[parent.container?.direction] ?? "group"}`}` : "";
|
|
95
|
+
const kind = node.kind === "block" ? "" : `, ${KIND_WORDS[node.kind] ?? node.kind}`;
|
|
96
|
+
const issues = node.findings.length ? `. ${node.findings.length === 1 ? "1 finding" : `${node.findings.length} findings`}: ${node.findings[0].message}` : "";
|
|
97
|
+
return `${node.label}${kind}${place}${issues}`;
|
|
98
|
+
}
|
|
99
|
+
const chipText = (node) => {
|
|
100
|
+
if (node.kind === "group") return node.label;
|
|
101
|
+
if (node.kind === "region") return node.label;
|
|
102
|
+
if (node.kind === "heading" || node.kind === "slide") return node.label.split(" · ")[0];
|
|
103
|
+
return node.label.split(":")[0];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// --- paint -------------------------------------------------------------------------------------------------------------------------------
|
|
107
|
+
function makeBox() {
|
|
108
|
+
const node = el("div", `${p}-box`);
|
|
109
|
+
node.addEventListener("focus", () => {
|
|
110
|
+
// Focus moved here by Tab (not by a selection change): select what has focus.
|
|
111
|
+
if (node.dataset.key && !isSelected(node.dataset.key) && !studio.selecting) studio.select([node.dataset.key], { source: "keyboard" });
|
|
112
|
+
});
|
|
113
|
+
return node;
|
|
114
|
+
}
|
|
115
|
+
function makeDivider() {
|
|
116
|
+
const node = el("div", `${p}-divider`);
|
|
117
|
+
node.addEventListener("pointerdown", (event) => startResize(event, node));
|
|
118
|
+
node.addEventListener("dblclick", (event) => { event.stopPropagation(); if (node._info?.draggable) studio.equalize(node._info.key, node._info.index); });
|
|
119
|
+
node.addEventListener("keydown", (event) => dividerKey(event, node));
|
|
120
|
+
return node;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Redraw the overlay from the scene and the selection. Cheap: elements are kept by key and only moved. */
|
|
124
|
+
function paint() {
|
|
125
|
+
if (destroyed) return;
|
|
126
|
+
const s = scene();
|
|
127
|
+
const selected = new Set(studio.selectedKeys());
|
|
128
|
+
const primary = studio.primaryKey();
|
|
129
|
+
const keepFocus = overlay.contains(doc.activeElement) || doc.activeElement === frame;
|
|
130
|
+
const pass = keyed.begin();
|
|
131
|
+
const tabStop = primary && s.node(primary)?.box ? primary : s.slideKey;
|
|
132
|
+
for (const node of s.nodes) {
|
|
133
|
+
if (!node.box || !(node.box.w > 0) || !(node.box.h > 0)) continue;
|
|
134
|
+
if (node.kind === "part" && !selected.has(node.key)) continue;
|
|
135
|
+
const box = pass.ensure(`n:${node.key}`, makeBox);
|
|
136
|
+
box.dataset.key = node.key;
|
|
137
|
+
box.dataset.kind = node.kind;
|
|
138
|
+
box.dataset.type = node.type;
|
|
139
|
+
box.dataset.depth = String(node.depth);
|
|
140
|
+
box.dataset.finding = node.findings.length ? node.findings[0].severity ?? "error" : "";
|
|
141
|
+
box.classList.toggle("is-primary", node.key === primary);
|
|
142
|
+
paintSelectable(box, { box: node.box, selected: selected.has(node.key), pressable: true, label: describe(node), tabStop: node.key === tabStop });
|
|
143
|
+
if (node.kind === "slide") box.setAttribute("aria-roledescription", "slide");
|
|
144
|
+
paintBadge(dom, box, node.findings.map((finding) => finding.message));
|
|
145
|
+
}
|
|
146
|
+
pass.done();
|
|
147
|
+
// The container outline of the primary's parent, and the label chip above the primary (outside its top edge).
|
|
148
|
+
const node = primary ? s.node(primary) : null;
|
|
149
|
+
const parent = node?.parent ? s.node(node.parent) : null;
|
|
150
|
+
parentOutline.hidden = !(parent?.box && parent.kind !== "slide");
|
|
151
|
+
if (!parentOutline.hidden) setBox(parentOutline, parent.box);
|
|
152
|
+
chip.hidden = !(node?.box && node.kind !== "slide");
|
|
153
|
+
if (!chip.hidden) {
|
|
154
|
+
chip.textContent = chipText(node);
|
|
155
|
+
chip.style.left = pct(node.box.x);
|
|
156
|
+
chip.style.top = pct(node.box.y);
|
|
157
|
+
}
|
|
158
|
+
paintDividers(s);
|
|
159
|
+
paintEmpty(s);
|
|
160
|
+
paintPlaceholders(s);
|
|
161
|
+
paintHover();
|
|
162
|
+
frame.setAttribute("aria-label", studio.frameLabel());
|
|
163
|
+
if (keepFocus && !studio.editingText()) focusPrimary();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The empty state: a slide with no content gets a prompt with Insert and Layout.
|
|
167
|
+
function paintEmpty(s) {
|
|
168
|
+
const content = s.nodes.filter((node) => node.kind !== "slide" && node.kind !== "heading" && node.kind !== "region" && node.kind !== "part");
|
|
169
|
+
const regions = s.nodes.filter((node) => node.kind === "region");
|
|
170
|
+
const show = !content.length && !regions.length && studio.layout === "studio";
|
|
171
|
+
if (show === !empty.hidden) return;
|
|
172
|
+
empty.hidden = !show;
|
|
173
|
+
if (!show) { empty.replaceChildren(); return; }
|
|
174
|
+
const insert = dom.button(`${p}-empty-button`, { text: "Insert a block", iconName: "Plus", size: 14 });
|
|
175
|
+
insert.addEventListener("click", () => studio.openInsertMenu(insert));
|
|
176
|
+
const layout = dom.button(`${p}-empty-button`, { text: "Choose a layout", iconName: "Columns2", size: 14 });
|
|
177
|
+
layout.addEventListener("click", () => studio.openSlideMenu(layout, { apply: true }));
|
|
178
|
+
empty.replaceChildren(el("p", `${p}-empty-text`, { text: "Insert a block or choose a layout" }), insert, layout);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function paintPlaceholders(s) {
|
|
182
|
+
const pass = keyedPlaceholders.begin();
|
|
183
|
+
if (studio.layout === "studio") {
|
|
184
|
+
for (const region of s.nodes) {
|
|
185
|
+
if (region.kind !== "region" || !region.virtual || !region.box || s.contentChildren(region.key).length) continue;
|
|
186
|
+
const node = pass.ensure(region.key, () => {
|
|
187
|
+
const box = el("div", `${p}-placeholder`);
|
|
188
|
+
const add = dom.button(`${p}-placeholder-add`, { iconName: "Plus", size: 14 });
|
|
189
|
+
add.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
190
|
+
add.addEventListener("click", (event) => { event.stopPropagation(); studio.openInsertMenu(add, { target: box.dataset.key }); });
|
|
191
|
+
box.append(el("span", `${p}-placeholder-text`), add);
|
|
192
|
+
return box;
|
|
193
|
+
});
|
|
194
|
+
node.dataset.key = region.key;
|
|
195
|
+
setBox(node, region.box);
|
|
196
|
+
node.firstChild.textContent = region.accepts?.length ? region.accepts.join(", ") : "Any content";
|
|
197
|
+
const add = node.lastChild;
|
|
198
|
+
add.setAttribute("aria-label", `Insert into region ${region.region}`);
|
|
199
|
+
add.title = `Insert into region ${region.region}`;
|
|
200
|
+
add.disabled = studio.readOnly;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
pass.done();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The dividers of the primary's container (or of the selected container): one between each pair of tracks, a grid's columns only.
|
|
207
|
+
function dividerContainer(s) {
|
|
208
|
+
const primary = studio.primaryKey();
|
|
209
|
+
const node = primary ? s.node(primary) : null;
|
|
210
|
+
if (!node) return null;
|
|
211
|
+
const container = CONTAINER_KINDS.has(node.kind) && node.container?.tracks?.length >= 2 ? node : node.parent ? s.node(node.parent) : null;
|
|
212
|
+
if (!container?.container || !["grid", "row", "column"].includes(container.container.mode)) return null;
|
|
213
|
+
return container.container.tracks.length >= 2 ? container : null;
|
|
214
|
+
}
|
|
215
|
+
function paintDividers(s) {
|
|
216
|
+
const pass = keyedDividers.begin();
|
|
217
|
+
const owner = studio.layout === "studio" ? dividerContainer(s) : null;
|
|
218
|
+
if (owner) {
|
|
219
|
+
const { container, box } = owner;
|
|
220
|
+
const flow = s.flowOf(owner.key);
|
|
221
|
+
const drawn = flow ? drawnTracks(flow) : null;
|
|
222
|
+
const row = container.direction !== "column";
|
|
223
|
+
const draggable = container.resizable && !studio.readOnly && Boolean(flow);
|
|
224
|
+
container.tracks.slice(0, -1).forEach((track, index) => {
|
|
225
|
+
const next = container.tracks[index + 1];
|
|
226
|
+
const at = (track.start + track.length + next.start) / 2;
|
|
227
|
+
const node = pass.ensure(`d:${owner.key}:${index}`, makeDivider);
|
|
228
|
+
const share = track.length / (track.length + next.length);
|
|
229
|
+
const weights = drawn && !drawn.drawn ? [drawn.weights[index], drawn.weights[index + 1]] : null;
|
|
230
|
+
node._info = { key: owner.key, index, draggable, row, pairStart: track.start, pairLength: next.start + next.length - track.start, box: row ? { x: at, y: box.y, w: 0, h: box.h } : { x: box.x, y: at, w: box.w, h: 0 } };
|
|
231
|
+
node.dataset.active = resize?.node === node ? "true" : "false";
|
|
232
|
+
paintDivider(node, {
|
|
233
|
+
box: node._info.box, orientation: row ? "vertical" : "horizontal", draggable, tabbable: tool !== "text",
|
|
234
|
+
label: `Resize ${row ? "columns" : "rows"} ${index + 1} and ${index + 2} of ${owner.label}`,
|
|
235
|
+
valueNow: Math.round(share * 100), valueText: weights ? ratioLabel(weights[0], weights[1]) : `${Math.round(share * 100)}% : ${100 - Math.round(share * 100)}%`,
|
|
236
|
+
title: "Drag to resize, Shift to resize freely. Double-click to make both sides equal.",
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
pass.done();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function paintHover() {
|
|
244
|
+
const node = hover && !resize && !drag.active && enabled ? scene().node(hover) : null;
|
|
245
|
+
hoverBox.hidden = !node?.box || isSelected(node.key) || node.kind === "slide";
|
|
246
|
+
if (!hoverBox.hidden) setBox(hoverBox, node.box);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Put focus on the primary's box (or the slide's), after the selection or the structure changed. A pointer press moves focus without the
|
|
251
|
+
* focus ring (`visible: false`); the keyboard shows it.
|
|
252
|
+
*/
|
|
253
|
+
function focusPrimary({ visible } = {}) {
|
|
254
|
+
if (!enabled) return;
|
|
255
|
+
const key = studio.primaryKey() ?? scene().slideKey;
|
|
256
|
+
const node = boxEl(key) ?? boxEl(scene().slideKey);
|
|
257
|
+
if (node && doc.activeElement !== node) {
|
|
258
|
+
studio.selecting = true;
|
|
259
|
+
try { node.focus({ preventScroll: true, ...(visible === undefined ? {} : { focusVisible: visible }) }); } finally { studio.selecting = false; }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- pointer: select, drag, context menu, text, pan ---------------------------------------------------------------------------------------
|
|
264
|
+
const drag = createDragController({
|
|
265
|
+
doc, threshold: 4, holdMs: 150,
|
|
266
|
+
onStart: (press) => startDrag(press),
|
|
267
|
+
onStarted: () => { root.dataset.dragging = "true"; paintHover(); },
|
|
268
|
+
onMove: (session, event) => moveDrag(session, event),
|
|
269
|
+
onEnd: (session) => endDrag(session),
|
|
270
|
+
onDrop: (session) => finishDrag(session),
|
|
271
|
+
onEscape: () => studio.announce("Move cancelled."),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const chainAt = (point, deep) => adapter.hitChain(point, undefined, { parts: deep });
|
|
275
|
+
const targetAt = (point, event) => {
|
|
276
|
+
const deep = Boolean(event.metaKey || event.ctrlKey);
|
|
277
|
+
return adapter.target(chainAt(point, deep), { primary: studio.primaryKey(), shift: event.shiftKey, deep });
|
|
278
|
+
};
|
|
279
|
+
const outside = (point) => point.x < 0 || point.y < 0 || point.x > 1 || point.y > 1;
|
|
280
|
+
|
|
281
|
+
function onPointerDown(event) {
|
|
282
|
+
if (!enabled || view.panMode || event.target.closest?.(`.${p}-divider, .${p}-empty, .${p}-menu`)) return;
|
|
283
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
284
|
+
if (studio.editingText()) return;
|
|
285
|
+
const point = unit(event);
|
|
286
|
+
clearLongPress();
|
|
287
|
+
if (tool === "text") {
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
studio.insertAtPoint("text", point);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const hit = targetAt(point, event);
|
|
293
|
+
if (!hit) return;
|
|
294
|
+
event.preventDefault();
|
|
295
|
+
if (event.shiftKey) { studio.selectAdd(hit.key, { source: "canvas" }); focusPrimary({ visible: false }); return; }
|
|
296
|
+
if (!isSelected(hit.key)) studio.select([hit.key], { source: "canvas" });
|
|
297
|
+
focusPrimary({ visible: false });
|
|
298
|
+
// A touch long-press (500 ms, no movement) opens the context menu.
|
|
299
|
+
if (event.pointerType === "touch") {
|
|
300
|
+
longPress = { id: event.pointerId, x: event.clientX, y: event.clientY, timer: win.setTimeout(() => { longPress = null; drag.cancel(); studio.openContextMenu({ x: event.clientX, y: event.clientY }); }, 500) };
|
|
301
|
+
}
|
|
302
|
+
const keys = studio.selectedKeys();
|
|
303
|
+
const nodes = keys.map((key) => scene().node(key)).filter(Boolean);
|
|
304
|
+
if (!studio.readOnly && nodes.length && nodes.every((node) => node.can.move)) {
|
|
305
|
+
drag.press(event, { keys });
|
|
306
|
+
try { frame.setPointerCapture?.(event.pointerId); } catch { /* a pointer that cannot be captured (a synthetic one) */ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function clearLongPress() { if (longPress) { win.clearTimeout(longPress.timer); longPress = null; } }
|
|
310
|
+
function onPointerMove(event) {
|
|
311
|
+
if (longPress && Math.hypot(event.clientX - longPress.x, event.clientY - longPress.y) > 8) clearLongPress();
|
|
312
|
+
if (insertDrag) return;
|
|
313
|
+
if (drag.move(event)) { clearLongPress(); return; }
|
|
314
|
+
if (event.pointerType === "touch" || !enabled || view.panMode) return;
|
|
315
|
+
const point = unit(event);
|
|
316
|
+
const next = tool === "select" && !outside(point) ? targetAt(point, event)?.key ?? null : null;
|
|
317
|
+
if (next !== hover) { hover = next; paintHover(); }
|
|
318
|
+
}
|
|
319
|
+
function onPointerUp(event) { clearLongPress(); drag.release(event); }
|
|
320
|
+
function onPointerCancel() { clearLongPress(); drag.cancel(); }
|
|
321
|
+
function onDoubleClick(event) {
|
|
322
|
+
if (!enabled || tool !== "select" || event.target.closest?.(`.${p}-divider, .${p}-empty`)) return;
|
|
323
|
+
const point = unit(event);
|
|
324
|
+
const hit = targetAt(point, event);
|
|
325
|
+
if (!hit) return;
|
|
326
|
+
const node = hit.node;
|
|
327
|
+
event.preventDefault();
|
|
328
|
+
if (node.kind === "group" || node.kind === "region" || node.kind === "slide") {
|
|
329
|
+
// One level in: the child under the pointer.
|
|
330
|
+
const chain = chainAt(point, false);
|
|
331
|
+
const at = chain.findIndex((entry) => entry.key === node.key);
|
|
332
|
+
const child = chain[at + 1];
|
|
333
|
+
if (child) { studio.select([child.key], { source: "canvas" }); focusPrimary({ visible: false }); }
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (studio.readOnly) { studio.announce("This editor is read-only."); return; }
|
|
337
|
+
if (node.type === "image" && node.kind !== "part") { studio.select([node.key], { source: "canvas" }); studio.cropImage(); return; }
|
|
338
|
+
if ((node.type === "chart" || node.type === "table") && node.kind !== "part") { studio.select([node.key], { source: "canvas" }); studio.editData(); return; }
|
|
339
|
+
studio.beginTextEdit(node.key, { point: { clientX: event.clientX, clientY: event.clientY } });
|
|
340
|
+
}
|
|
341
|
+
function onContextMenu(event) {
|
|
342
|
+
if (!enabled || event.target.closest?.(`.${p}-menu`)) return;
|
|
343
|
+
const point = unit(event);
|
|
344
|
+
const hit = outside(point) ? null : targetAt(point, event);
|
|
345
|
+
event.preventDefault();
|
|
346
|
+
if (hit && !isSelected(hit.key)) studio.select([hit.key], { source: "canvas" });
|
|
347
|
+
focusPrimary({ visible: false });
|
|
348
|
+
studio.openContextMenu({ x: event.clientX, y: event.clientY });
|
|
349
|
+
}
|
|
350
|
+
// A press on the viewport outside the slide selects nothing (the deck).
|
|
351
|
+
function onViewportDown(event) {
|
|
352
|
+
if (!enabled || view.panMode || frame.contains(event.target) || event.target.closest?.(`.${p}-actionbar, .${p}-menu`)) return;
|
|
353
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
354
|
+
if (studio.editingText()) return;
|
|
355
|
+
studio.select([], { source: "canvas" });
|
|
356
|
+
frame.focus({ preventScroll: true });
|
|
357
|
+
}
|
|
358
|
+
frame.addEventListener("pointerdown", onPointerDown);
|
|
359
|
+
frame.addEventListener("pointermove", onPointerMove);
|
|
360
|
+
frame.addEventListener("pointerup", onPointerUp);
|
|
361
|
+
frame.addEventListener("pointercancel", onPointerCancel);
|
|
362
|
+
frame.addEventListener("pointerleave", () => { if (!drag.active) { hover = null; paintHover(); } });
|
|
363
|
+
frame.addEventListener("dblclick", onDoubleClick);
|
|
364
|
+
frame.addEventListener("contextmenu", onContextMenu);
|
|
365
|
+
viewport.addEventListener("pointerdown", onViewportDown);
|
|
366
|
+
|
|
367
|
+
// --- drag to move (the kit's drag controller over the adapter's trial drops) ---------------------------------------------------------------
|
|
368
|
+
function startDrag(press) {
|
|
369
|
+
if (studio.readOnly) return null;
|
|
370
|
+
const keys = press.payload.keys.filter((key) => scene().node(key)?.can.move);
|
|
371
|
+
if (!keys.length) return null;
|
|
372
|
+
const first = scene().node(keys[0]);
|
|
373
|
+
const ghost = dragGhost(dom, root, keys.length > 1 ? `${keys.length} blocks` : chipText(first));
|
|
374
|
+
for (const key of keys) boxEl(key)?.classList.add("is-dragging");
|
|
375
|
+
return { keys, ghost, drop: null };
|
|
376
|
+
}
|
|
377
|
+
function autoScroll(event) {
|
|
378
|
+
const box = viewport.getBoundingClientRect();
|
|
379
|
+
const edge = 24, speed = 12;
|
|
380
|
+
if (event.clientX < box.left + edge) viewport.scrollLeft -= speed; else if (event.clientX > box.right - edge) viewport.scrollLeft += speed;
|
|
381
|
+
if (event.clientY < box.top + edge) viewport.scrollTop -= speed; else if (event.clientY > box.bottom - edge) viewport.scrollTop += speed;
|
|
382
|
+
}
|
|
383
|
+
function showDrop(found) {
|
|
384
|
+
marks.into(layer, null);
|
|
385
|
+
if (!found) { marks.hideDrop(); return; }
|
|
386
|
+
marks.showDrop(found.line, { orientation: found.line.orientation, state: found.ok ? (found.noop ? "same" : "ok") : "refused", reason: found.reason });
|
|
387
|
+
if (found.into && found.ok) marks.into(layer, boxEl(found.into));
|
|
388
|
+
}
|
|
389
|
+
function moveDrag(session, event) {
|
|
390
|
+
session.ghost.moveTo(event);
|
|
391
|
+
autoScroll(event);
|
|
392
|
+
const found = adapter.dropAt(unit(event), session.keys);
|
|
393
|
+
session.drop = found;
|
|
394
|
+
showDrop(found);
|
|
395
|
+
if (found && !found.ok) studio.status(found.reason);
|
|
396
|
+
}
|
|
397
|
+
function endDrag(session) {
|
|
398
|
+
session.ghost.remove();
|
|
399
|
+
marks.hideDrop();
|
|
400
|
+
marks.into(layer, null);
|
|
401
|
+
delete root.dataset.dragging;
|
|
402
|
+
for (const node of layer.querySelectorAll(".is-dragging")) node.classList.remove("is-dragging");
|
|
403
|
+
}
|
|
404
|
+
function finishDrag(session) {
|
|
405
|
+
const found = session.drop;
|
|
406
|
+
if (!found) return;
|
|
407
|
+
if (!found.ok) { studio.announce(found.reason); return; }
|
|
408
|
+
if (found.noop) return;
|
|
409
|
+
studio.move(session.keys, found.target, found.index);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Dragging a new block from Insert onto the slide: the same drop lines, a region that does not accept the kind refuses.
|
|
413
|
+
function beginInsertDrag(kind, event, { onEnd } = {}) {
|
|
414
|
+
if (studio.readOnly || insertDrag) return false;
|
|
415
|
+
const ghost = dragGhost(dom, root, `New ${kind}`);
|
|
416
|
+
insertDrag = { kind, ghost, drop: null, id: event.pointerId };
|
|
417
|
+
root.dataset.dragging = "true";
|
|
418
|
+
const move = (next) => {
|
|
419
|
+
if (next.pointerId !== insertDrag?.id) return;
|
|
420
|
+
ghost.moveTo(next);
|
|
421
|
+
const point = unit(next);
|
|
422
|
+
const found = outside(point) ? null : adapter.dropAt(point, []);
|
|
423
|
+
const verdict = found ? studio.insertVerdict(kind, found.target) : null;
|
|
424
|
+
insertDrag.drop = found ? { ...found, ok: verdict === true, noop: false, reason: verdict === true ? "" : verdict } : null;
|
|
425
|
+
showDrop(insertDrag.drop);
|
|
426
|
+
if (insertDrag.drop && !insertDrag.drop.ok) studio.status(insertDrag.drop.reason);
|
|
427
|
+
};
|
|
428
|
+
const finish = (commit) => {
|
|
429
|
+
const session = insertDrag;
|
|
430
|
+
if (!session) return;
|
|
431
|
+
insertDrag = null;
|
|
432
|
+
doc.removeEventListener("pointermove", move, true);
|
|
433
|
+
doc.removeEventListener("pointerup", up, true);
|
|
434
|
+
doc.removeEventListener("pointercancel", cancel, true);
|
|
435
|
+
doc.removeEventListener("keydown", key, true);
|
|
436
|
+
ghost.remove();
|
|
437
|
+
marks.hideDrop();
|
|
438
|
+
marks.into(layer, null);
|
|
439
|
+
delete root.dataset.dragging;
|
|
440
|
+
onEnd?.();
|
|
441
|
+
if (!commit) { studio.announce("Insert cancelled."); return; }
|
|
442
|
+
const found = session.drop;
|
|
443
|
+
if (!found) return;
|
|
444
|
+
if (!found.ok) { studio.announce(found.reason); return; }
|
|
445
|
+
studio.insert(session.kind, { target: found.target, index: found.index });
|
|
446
|
+
};
|
|
447
|
+
const up = (next) => { if (next.pointerId === insertDrag?.id) finish(true); };
|
|
448
|
+
const cancel = (next) => { if (next.pointerId === insertDrag?.id) finish(false); };
|
|
449
|
+
const key = (next) => { if (next.key === "Escape") { next.preventDefault(); next.stopPropagation(); finish(false); } };
|
|
450
|
+
doc.addEventListener("pointermove", move, true);
|
|
451
|
+
doc.addEventListener("pointerup", up, true);
|
|
452
|
+
doc.addEventListener("pointercancel", cancel, true);
|
|
453
|
+
doc.addEventListener("keydown", key, true);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// --- dividers (track resize through the adapter's gesture: drafts, then one change) ---------------------------------------------------------
|
|
458
|
+
function startResize(event, node) {
|
|
459
|
+
const info = node._info;
|
|
460
|
+
if (!info?.draggable || studio.readOnly || view.panMode || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
event.stopPropagation();
|
|
463
|
+
if (!adapter.begin("Resize")) return;
|
|
464
|
+
resize = { node, info, label: null, id: event.pointerId };
|
|
465
|
+
try { node.setPointerCapture?.(event.pointerId); } catch { /* a pointer that cannot be captured (a synthetic one) */ }
|
|
466
|
+
node.focus({ preventScroll: true });
|
|
467
|
+
node.dataset.active = "true";
|
|
468
|
+
trackPointer(node, {
|
|
469
|
+
doc,
|
|
470
|
+
onMove(move) {
|
|
471
|
+
const point = unit(move);
|
|
472
|
+
const along = info.row ? point.x : point.y;
|
|
473
|
+
const fraction = Math.max(0, Math.min(1, (along - info.pairStart) / info.pairLength));
|
|
474
|
+
try {
|
|
475
|
+
const prepared = adapter.prepare("resizeTracks", info.key, info.index, fraction, { free: move.shiftKey });
|
|
476
|
+
const label = ratioLabel(prepared.weights[info.index], prepared.weights[info.index + 1]);
|
|
477
|
+
resize.label = label;
|
|
478
|
+
adapter.preview(prepared, `Resize ${label}`);
|
|
479
|
+
marks.showRatio(label, { x: info.box.x + info.box.w / 2, y: info.box.y + info.box.h / 2 });
|
|
480
|
+
} catch (error) {
|
|
481
|
+
studio.status(error.message);
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
onEnd(commit) {
|
|
485
|
+
marks.hideRatio();
|
|
486
|
+
const label = resize?.label;
|
|
487
|
+
resize = null;
|
|
488
|
+
node.dataset.active = "false";
|
|
489
|
+
if (commit && label) {
|
|
490
|
+
if (adapter.commit(`Resized to ${label}`)) studio.announce(`Resized to ${label}`);
|
|
491
|
+
} else {
|
|
492
|
+
adapter.cancel();
|
|
493
|
+
if (!commit) studio.announce("Resize cancelled.");
|
|
494
|
+
}
|
|
495
|
+
studio.afterGesture();
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
function dividerKey(event, node) {
|
|
500
|
+
const info = node._info;
|
|
501
|
+
if (!info?.draggable) return;
|
|
502
|
+
const action = dividerKeyAction(event, info.row ? "vertical" : "horizontal");
|
|
503
|
+
if (!action) return;
|
|
504
|
+
event.preventDefault();
|
|
505
|
+
event.stopPropagation();
|
|
506
|
+
if (action.type === "equalize") studio.equalize(info.key, info.index);
|
|
507
|
+
else studio.nudgeDivider(info.key, info.index, action.step, action.big);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// --- keyboard on the slide ---------------------------------------------------------------------------------------------------------------------
|
|
511
|
+
function onKeyDown(event) {
|
|
512
|
+
if (!enabled || isTypingTarget(event.target)) return;
|
|
513
|
+
if (event.key === " " && !event.repeat && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
514
|
+
event.preventDefault();
|
|
515
|
+
view.holdSpace(true);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
// Tab and Shift+Tab: the next or previous sibling; past either end, focus leaves the slide as usual.
|
|
519
|
+
if (event.key === "Tab" && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
520
|
+
const delta = event.shiftKey ? -1 : 1;
|
|
521
|
+
const key = studio.primaryKey();
|
|
522
|
+
if (key && scene().node(key) && scene().node(key).kind !== "slide" && studio.hasSibling(delta) === true) {
|
|
523
|
+
event.preventDefault();
|
|
524
|
+
studio.selectSibling(delta);
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
// Typing a character on a selected text node replaces its text (Figma); a shortcut other than a tool's letter ("?", Shift+1) keeps its command.
|
|
529
|
+
if (event.key.length === 1 && event.key !== "?" && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing && studio.textTarget() && !studio.readOnly && studio.inSync && !studio.isShortcut(event)) {
|
|
530
|
+
event.preventDefault();
|
|
531
|
+
studio.beginTextEdit(studio.primaryKey(), { replace: event.key });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function onKeyUp(event) { if (event.key === " ") view.holdSpace(false); }
|
|
535
|
+
frame.addEventListener("keydown", onKeyDown);
|
|
536
|
+
frame.addEventListener("keyup", onKeyUp);
|
|
537
|
+
frame.addEventListener("focusout", () => view.holdSpace(false));
|
|
538
|
+
// A text field of the canvas: Tab commits and selects the next sibling.
|
|
539
|
+
frame.addEventListener("keydown", (event) => {
|
|
540
|
+
if (event.key !== "Tab" || !studio.editingText() || !event.target.closest?.(".opf-inline-input, .opf-rich-input, textarea")) return;
|
|
541
|
+
if (event.target.closest?.("textarea")?.style.tabSize === "4") return;
|
|
542
|
+
event.preventDefault();
|
|
543
|
+
event.stopPropagation();
|
|
544
|
+
if (canvas.commit()) studio.selectSibling(event.shiftKey ? -1 : 1);
|
|
545
|
+
}, true);
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
paint,
|
|
549
|
+
paintHover,
|
|
550
|
+
get zoom() { return view.zoom; },
|
|
551
|
+
get panMode() { return view.panMode; },
|
|
552
|
+
setZoom: (value, options) => view.setZoom(value, options),
|
|
553
|
+
step: (direction) => view.step(direction),
|
|
554
|
+
fit: (mode = "slide") => view.fit(mode),
|
|
555
|
+
get tool() { return tool; },
|
|
556
|
+
setTool(next) {
|
|
557
|
+
tool = next;
|
|
558
|
+
root.dataset.tool = next;
|
|
559
|
+
paintDividers(scene());
|
|
560
|
+
},
|
|
561
|
+
focusPrimary,
|
|
562
|
+
boxOf: boxEl,
|
|
563
|
+
describe,
|
|
564
|
+
beginInsertDrag,
|
|
565
|
+
get dragging() { return Boolean(drag.active || insertDrag || resize); },
|
|
566
|
+
cancelDrag() { drag.cancel(); },
|
|
567
|
+
/** Overlay on (the Studio) or off (the Playground preset: the canvas takes the pointer). */
|
|
568
|
+
setEnabled(on) {
|
|
569
|
+
enabled = Boolean(on);
|
|
570
|
+
root.dataset.overlay = enabled ? "on" : "off";
|
|
571
|
+
if (!enabled) { drag.cancel(); hover = null; paintHover(); }
|
|
572
|
+
},
|
|
573
|
+
destroy() {
|
|
574
|
+
destroyed = true;
|
|
575
|
+
clearLongPress();
|
|
576
|
+
drag.cancel();
|
|
577
|
+
view.destroy();
|
|
578
|
+
viewport.removeEventListener("wheel", onWheel);
|
|
579
|
+
viewport.removeEventListener("pointerdown", onViewportDown);
|
|
580
|
+
},
|
|
581
|
+
stage,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Whether a scene node holds text the canvas edits in place. */
|
|
586
|
+
export const isTextNode = (node) => Boolean(node) && (node.kind === "heading" || TEXT_TYPES.has(node.type)) && node.kind !== "region" && node.kind !== "group" && node.kind !== "slide";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** CE-20 (internal): the Studio's command registry (STUDIO-SPEC sections 6 and 14). */
|
|
2
|
+
import type { KitCommand, KitCommandRegistry } from "./studio-kit/commands.js";
|
|
3
|
+
|
|
4
|
+
export declare const STUDIO_INSERT_KINDS: readonly string[];
|
|
5
|
+
export declare const STUDIO_INSERT_LABELS: Readonly<Record<string, string>>;
|
|
6
|
+
export declare const STUDIO_INSERT_HINTS: Readonly<Record<string, string>>;
|
|
7
|
+
export declare const STUDIO_DOWNLOAD_FORMATS: readonly ("json" | "yaml" | "markdown")[];
|
|
8
|
+
/** Every command id the Studio registers, in registration order. */
|
|
9
|
+
export declare const STUDIO_COMMAND_IDS: readonly string[];
|
|
10
|
+
/** The Studio's command definitions; `when` and `run` take the Studio controller (src/studio.js). */
|
|
11
|
+
export declare function studioCommandDefinitions(): KitCommand<any>[];
|
|
12
|
+
/** A registry of the Studio's commands, then the host's `extra` ones, with the host's keymap overrides. */
|
|
13
|
+
export declare function createStudioCommands(options?: { extra?: KitCommand<any>[]; keymap?: Record<string, string | string[] | null> }): KitCommandRegistry<any>;
|
|
14
|
+
/** The context menu's groups of command ids (STUDIO-SPEC 4.7). */
|
|
15
|
+
export declare function contextMenuGroups(options?: { slide?: boolean }): string[][];
|