@openpresentation/opf-editor 0.28.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.
Files changed (53) hide show
  1. package/README.md +20 -6
  2. package/dist/canvas.d.ts +13 -1
  3. package/dist/canvas.js +39 -9
  4. package/dist/chart-options-panel.js +16 -16
  5. package/dist/codemirror-guard.js +69 -0
  6. package/dist/content-controls.js +127 -74
  7. package/dist/data-grid.js +15 -7
  8. package/dist/design-controls.d.ts +13 -0
  9. package/dist/design-controls.js +134 -124
  10. package/dist/exact-source.d.ts +30 -0
  11. package/dist/font-gate.js +20 -1
  12. package/dist/json-editor.js +8 -2
  13. package/dist/json-options.d.ts +2 -1
  14. package/dist/json-options.js +7 -6
  15. package/dist/layout-templates.d.ts +8 -3
  16. package/dist/layout-templates.js +30 -16
  17. package/dist/numbering-panel.js +2 -3
  18. package/dist/react.d.ts +12 -4
  19. package/dist/react.js +39 -5
  20. package/dist/slide-adapter.d.ts +49 -0
  21. package/dist/slide-adapter.js +289 -0
  22. package/dist/slide-edits.d.ts +46 -0
  23. package/dist/slide-edits.js +629 -0
  24. package/dist/slide-manager.js +2 -1
  25. package/dist/slide-scene.d.ts +96 -0
  26. package/dist/slide-scene.js +413 -0
  27. package/dist/source-checker.js +45 -6
  28. package/dist/source-completions.js +6 -4
  29. package/dist/source-pane.d.ts +18 -6
  30. package/dist/source-pane.js +45 -0
  31. package/dist/source-worker.js +8 -4
  32. package/dist/studio-canvas.d.ts +34 -0
  33. package/dist/studio-canvas.js +586 -0
  34. package/dist/studio-commands.d.ts +15 -0
  35. package/dist/studio-commands.js +155 -0
  36. package/dist/studio-design.d.ts +16 -0
  37. package/dist/studio-design.js +354 -0
  38. package/dist/studio-kit/dom.d.ts +16 -0
  39. package/dist/studio-kit/dom.js +42 -6
  40. package/dist/studio-layers.d.ts +12 -0
  41. package/dist/studio-layers.js +218 -0
  42. package/dist/studio.d.ts +31 -0
  43. package/dist/studio.js +1608 -0
  44. package/dist/styles.css +1546 -117
  45. package/dist/thumbnail-cache.d.ts +2 -0
  46. package/dist/thumbnail-cache.js +10 -1
  47. package/dist/workspace-measure.js +6 -4
  48. package/dist/workspace-parts.js +20 -0
  49. package/dist/workspace-shell.js +25 -15
  50. package/dist/workspace-theme.js +3 -0
  51. package/dist/workspace.d.ts +88 -9
  52. package/dist/workspace.js +210 -49
  53. package/package.json +29 -18
@@ -1,8 +1,10 @@
1
1
  // Content controls (RR-26): the DOM controls for the content actions, mounted by `createDesignControls` in its
2
- // "Selected content" and "Slide structure" sections. Each action is a native button or select with a visible label;
3
- // it shows what the change would not carry over BEFORE it is applied (a dry run through the same prepare function the
4
- // action uses), is disabled with its reason when it does not apply, and commits one undoable change through the
5
- // session. Nothing here owns document state: `sync` re-reads the session after every change, Undo and Redo included.
2
+ // "Selected content" and "Slide structure" sections. The structural actions of a block are one row of icon buttons
3
+ // (each named, with a tooltip); the others are native buttons or selects with a short label. An action runs a dry run
4
+ // through the same prepare function it uses, so before it is applied its tooltip names what it would not carry over,
5
+ // and when it does not apply it is shown disabled (aria-disabled, so it stays focusable) with the reason as its tooltip
6
+ // and accessible description. Each commits one undoable change through the session. Nothing here owns document state:
7
+ // `sync` re-reads the session after every change, Undo and Redo included.
6
8
  import { BLOCK_KIND_LABELS, blockConversionTargets, convertBlock, metricGroupForSelection } from "./block-convert.js";
7
9
  import {
8
10
  listItemIndexForSelection,
@@ -21,6 +23,7 @@ import {
21
23
  shiftListItems,
22
24
  ungroupBlock,
23
25
  } from "./content-actions.js";
26
+ import { ICONS } from "./studio-kit/dom.js";
24
27
 
25
28
  /** Region layouts offered for a slide with this many blocks (each block gets its own region, none overlap). */
26
29
  export const REGION_LAYOUTS = Object.freeze({
@@ -51,34 +54,67 @@ const DESIGN_IMAGE_LABELS = { background: "background image", watermark: "waterm
51
54
  // An image block's own framing fields (FA-22) do not stop it being "a block that holds only an image".
52
55
  const IMAGE_BLOCK_FIELDS = new Set(["id", "extensions", "type", "fit", "focus", "aspectRatio", "shape", "cornerRadius", "border", "opacity", "recolor", "overlay", "placement"]);
53
56
  const messageOf = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
54
- const lossText = (prepared) => (prepared.loss.length ? `Not carried over: ${prepared.loss.join(", ")}.` : "Nothing is lost.");
57
+ const lossText = (prepared) => (prepared.loss?.length ? `Not carried over: ${prepared.loss.join(", ")}.` : "");
58
+ const SVG = "http://www.w3.org/2000/svg";
59
+
60
+ /** An inline Lucide icon (aria-hidden) from the studio kit's set. */
61
+ export function iconNode(doc, name, size = 16) {
62
+ const svg = doc.createElementNS(SVG, "svg");
63
+ for (const [key, value] of Object.entries({ viewBox: "0 0 24 24", width: size, height: size, fill: "none", stroke: "currentColor", "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false", class: "opf-dc-icon" })) svg.setAttribute(key, String(value));
64
+ svg.innerHTML = ICONS[name] ?? "";
65
+ return svg;
66
+ }
67
+
68
+ /** The first sentence of a refusal, for a tooltip: "The first line is longer than 24 characters." */
69
+ export function shortReason(reason) {
70
+ if (!reason) return "";
71
+ const first = String(reason).split(/(?<=[.!?])\s/)[0];
72
+ return first.replace(/, which [^.]*\./, ".");
73
+ }
74
+
75
+ /**
76
+ * Show a button as available or not. A disabled action keeps its focus (aria-disabled) so the reason in its tooltip is reachable from the
77
+ * keyboard; the tooltip is the label (with its shortcut) when it is available, and the label and the reason when it is not.
78
+ */
79
+ export function setActionState(button, { label, enabled, reason, shortcut, loss }) {
80
+ if (enabled) button.removeAttribute("aria-disabled");
81
+ else button.setAttribute("aria-disabled", "true");
82
+ const tip = enabled ? `${label}${shortcut ? ` (${shortcut})` : ""}${loss ? `. ${loss}` : ""}` : `${label}: ${shortReason(reason) || "not available here."}`;
83
+ button.title = tip;
84
+ }
55
85
 
56
86
  /**
57
- * Build the content controls. `ctx` supplies the panel's helpers: `editor`, `h` (element builder), `selectField`,
58
- * `run(action, done)` (commit, report, restore on failure), `nextId`, `getSlide()` and `reselect(path)`.
59
- * Returns the nodes to place and `sync(state)`, where `state` is `{ blockPath, content, selectedPath, slideIndex }`.
87
+ * Build the content controls. `ctx` supplies the panel's helpers: `editor`, `doc`, `h` (element builder), `selectField`,
88
+ * `run(action, done)` (commit, report, restore on failure), `nextId`, `getSlide()`, `reselect(path)` and, optionally,
89
+ * `blockActions` (`{ before, after }`: host actions for the arrange toolbar, each `{ role, label, icon, state(), run() }`).
90
+ * Returns the nodes to place and `sync(state)`, where `state` is `{ blockPath, groupPath, content, selectedPath, slideIndex }`
91
+ * (`groupPath` is a selected group, which has no content of its own).
60
92
  */
61
93
  export function createContentControls(ctx) {
62
94
  const { editor, h, selectField, run, nextId, getSlide, reselect } = ctx;
63
95
  const updaters = [];
64
96
  const ctxState = { current: {} };
65
97
 
66
- /** A button that shows the dry-run report under it before it is pressed and is disabled, with the reason, when it does not apply. */
67
- function action(label, { prepare, apply, done, selectAfter, role }) {
68
- const id = nextId("content-note");
69
- const note = h("p", { class: "opf-dc-help", id, "data-role": `${role}-note` });
70
- const button = h("button", { type: "button", class: "secondary", "aria-describedby": id, "data-role": role }, label);
98
+ /**
99
+ * A button for one content action: an icon button in a toolbar when `icon` is given, otherwise a text button. Before it is pressed its
100
+ * tooltip names what the change would not carry over; it is disabled, with the reason as its tooltip, when it does not apply.
101
+ */
102
+ function action(label, { prepare, apply, done, selectAfter, role, icon }) {
103
+ const button = icon
104
+ ? h("button", { type: "button", class: "opf-dc-tool", "aria-label": label, "data-role": role }, iconNode(ctx.doc, icon))
105
+ : h("button", { type: "button", class: "secondary", "data-role": role }, label);
71
106
  button.addEventListener("click", () => {
107
+ if (button.getAttribute("aria-disabled") === "true") return;
72
108
  run(
73
109
  () => {
74
110
  const change = apply();
75
111
  if (selectAfter) reselect(selectAfter(change));
76
112
  return change;
77
113
  },
78
- (change) => `${typeof done === "function" ? done(change) : done} ${lossText(change)}`,
114
+ (change) => `${typeof done === "function" ? done(change) : done}${lossText(change) ? ` ${lossText(change)}` : ""}`,
79
115
  );
80
116
  });
81
- const wrap = h("div", { class: "opf-dc-action" }, button, note);
117
+ const wrap = icon ? button : h("div", { class: "opf-dc-action" }, button);
82
118
  let current = {};
83
119
  const update = (state) => {
84
120
  current = state;
@@ -86,19 +122,28 @@ export function createContentControls(ctx) {
86
122
  try {
87
123
  prepared = prepare(state);
88
124
  } catch (error) {
89
- button.disabled = true;
90
- note.textContent = messageOf(error);
125
+ setActionState(button, { label, enabled: false, reason: messageOf(error) });
91
126
  return;
92
127
  }
93
- button.disabled = !prepared.changed;
94
- note.textContent = prepared.changed ? lossText(prepared) : (prepared.reason ?? "");
128
+ setActionState(button, { label, enabled: prepared.changed, reason: prepared.reason, loss: prepared.changed ? lossText(prepared) : "" });
95
129
  };
96
- return { wrap, update, state: () => current };
130
+ return { wrap, button, update, state: () => current };
131
+ }
132
+
133
+ /** A host action in the toolbar (the Studio's move, duplicate and delete): `{ role, label, icon, state() → { enabled, reason, shortcut }, run() }`. */
134
+ function hostAction(entry) {
135
+ const button = h("button", { type: "button", class: "opf-dc-tool", "aria-label": entry.label, "data-role": entry.role }, iconNode(ctx.doc, entry.icon));
136
+ button.addEventListener("click", () => {
137
+ if (button.getAttribute("aria-disabled") === "true") return;
138
+ entry.run();
139
+ });
140
+ return { wrap: button, update: () => setActionState(button, { label: entry.label, ...entry.state() }) };
97
141
  }
98
142
 
99
143
  // --- list levels ---------------------------------------------------------------------------------
100
144
 
101
- const list = h("div", { class: "opf-dc-actions-block", role: "group", "aria-label": "List levels", "data-role": "list-levels" });
145
+ const listTools = h("div", { class: "opf-dc-toolbar", role: "group", "aria-label": "List level" });
146
+ const list = h("div", { class: "opf-dc-row", "data-role": "list-levels" }, h("span", { class: "opf-dc-row-label", "aria-hidden": "true" }, "List level"), listTools);
102
147
  {
103
148
  const index = (state) => listItemIndexForSelection(state.selectedPath, state.blockPath);
104
149
  const prepare = (delta) => (state) => {
@@ -110,9 +155,9 @@ export function createContentControls(ctx) {
110
155
  const state = ctxState.current;
111
156
  return shiftListItems(editor, state.blockPath, [index(state)], delta);
112
157
  };
113
- const indent = action("Indent item", { role: "indent", prepare: prepare(1), apply: apply(1), done: "Item moved in." });
114
- const outdent = action("Outdent item", { role: "outdent", prepare: prepare(-1), apply: apply(-1), done: "Item moved out." });
115
- list.append(h("p", { class: "opf-dc-help" }, "Select an item, then change its level. Items under it move with it."), indent.wrap, outdent.wrap);
158
+ const outdent = action("Outdent item", { role: "outdent", icon: "IndentDecrease", prepare: prepare(-1), apply: apply(-1), done: "Item moved out." });
159
+ const indent = action("Indent item", { role: "indent", icon: "IndentIncrease", prepare: prepare(1), apply: apply(1), done: "Item moved in." });
160
+ listTools.append(outdent.wrap, indent.wrap);
116
161
  updaters.push((state) => {
117
162
  list.hidden = state.content?.kind !== "list";
118
163
  if (!list.hidden) {
@@ -125,14 +170,13 @@ export function createContentControls(ctx) {
125
170
  // --- a group of metrics --------------------------------------------------------------------------
126
171
 
127
172
  const metrics = selectField("metric-group", "Group of metrics", {
128
- empty: "Convert the group to…",
129
- help: "Every block in this group is a metric. Converting keeps their labels, values, units and deltas.",
173
+ empty: "Convert to…",
130
174
  onChange: (value) => {
131
175
  if (!value) return;
132
176
  const path = ctxState.current.metricGroupPath;
133
177
  run(
134
178
  () => convertBlock(editor, path, value),
135
- (change) => `Converted the group to ${BLOCK_KIND_LABELS[value]?.toLowerCase() ?? value}. ${lossText(change)}`,
179
+ (change) => `Converted the group to ${BLOCK_KIND_LABELS[value]?.toLowerCase() ?? value}.${lossText(change) ? ` ${lossText(change)}` : ""}`,
136
180
  );
137
181
  reselect(path);
138
182
  },
@@ -144,9 +188,9 @@ export function createContentControls(ctx) {
144
188
  metrics.setOptions(
145
189
  targets.map((target) => ({
146
190
  value: target.kind,
147
- label: target.available ? `${target.label}${target.lossless ? "" : ` (loses ${target.loss.join(", ")})`}` : `${target.label} (unavailable)`,
191
+ label: target.available ? `${target.label}${target.lossless ? "" : ` (loses ${target.loss.join(", ")})`}` : target.label,
148
192
  disabled: !target.available,
149
- title: target.reason,
193
+ title: target.available ? undefined : shortReason(target.reason),
150
194
  })),
151
195
  );
152
196
  metrics.set("");
@@ -154,9 +198,25 @@ export function createContentControls(ctx) {
154
198
 
155
199
  // --- arrange the selected block -------------------------------------------------------------------
156
200
 
157
- const arrange = h("div", { class: "opf-dc-actions-block", role: "group", "aria-label": "Arrange the selected block", "data-role": "arrange" });
201
+ // One row of icon buttons: the host's actions before (the Studio's Move up and Move down), Group with the next block and Ungroup, then the
202
+ // host's actions after (Duplicate, Delete). `target` is the selected block, or the selected group (which has no content to convert).
203
+ const arrange = h("div", { class: "opf-dc-toolbar", role: "group", "aria-label": "Arrange", "data-role": "arrange" });
204
+ const imageUse = selectField("image-destination", "Use image as", {
205
+ empty: "Choose…",
206
+ onChange: (value) => {
207
+ const destination = IMAGE_DESTINATIONS.find((entry) => entry.value === value);
208
+ if (!destination) return;
209
+ const state = ctxState.current;
210
+ run(
211
+ () => moveImageToDesign(editor, state.blockPath, destination.target, destination.options),
212
+ (change) => `Image is now the ${destination.label.toLowerCase()}.${lossText(change) ? ` ${lossText(change)}` : ""}`,
213
+ );
214
+ reselect(`slides.${state.slideIndex}`);
215
+ },
216
+ });
158
217
  {
159
- const parts = (state) => state.blockPath?.split(".") ?? [];
218
+ const target = (state) => state.blockPath ?? state.groupPath;
219
+ const parts = (state) => target(state)?.split(".") ?? [];
160
220
  const blockIndex = (state) => {
161
221
  const found = parts(state);
162
222
  return found.at(-2) === "blocks" && /^\d+$/.test(found.at(-1)) ? Number(found.at(-1)) : undefined;
@@ -169,9 +229,10 @@ export function createContentControls(ctx) {
169
229
  };
170
230
  const group = action("Group with the next block", {
171
231
  role: "group-next",
232
+ icon: "Group",
172
233
  prepare: (state) => {
173
234
  const indices = next(state);
174
- if (!indices) return { changed: false, loss: [], reason: "There is no block after this one in its container." };
235
+ if (!indices) return { changed: false, loss: [], reason: "There is no block after this one." };
175
236
  return prepareGroupBlocks(editor.presentation, containerPath(state), indices, { validate: false });
176
237
  },
177
238
  apply: () => {
@@ -181,56 +242,49 @@ export function createContentControls(ctx) {
181
242
  selectAfter: (change) => change.path,
182
243
  done: "Blocks grouped.",
183
244
  });
184
- const parentGroup = (state) => {
245
+ // The group to dissolve: the selected group itself, or the group the selected block is in.
246
+ const groupToUndo = (state) => {
247
+ if (state.groupPath && !state.blockPath) return state.groupPath;
185
248
  const found = parts(state);
186
249
  return found.length >= 6 && found.at(-4) === "blocks" ? found.slice(0, -2).join(".") : undefined;
187
250
  };
188
- const ungroup = action("Ungroup these blocks", {
251
+ const ungroup = action("Ungroup", {
189
252
  role: "ungroup",
253
+ icon: "Ungroup",
190
254
  prepare: (state) => {
191
- const path = parentGroup(state);
255
+ const path = groupToUndo(state);
192
256
  if (!path) return { changed: false, loss: [], reason: "This block is not inside a group." };
193
257
  return prepareUngroupBlock(editor.presentation, path, { validate: false });
194
258
  },
195
- apply: () => ungroupBlock(editor, parentGroup(ctxState.current)),
259
+ apply: () => ungroupBlock(editor, groupToUndo(ctxState.current)),
196
260
  selectAfter: (change) => change.path,
197
261
  done: "Group dissolved.",
198
262
  });
199
- const image = selectField("image-destination", "Use this image as", {
200
- empty: "Choose where it goes…",
201
- help: "Moves the image out of the content into the slide's design. Undo puts it back.",
202
- onChange: (value) => {
203
- const destination = IMAGE_DESTINATIONS.find((entry) => entry.value === value);
204
- if (!destination) return;
205
- const state = ctxState.current;
206
- run(
207
- () => moveImageToDesign(editor, state.blockPath, destination.target, destination.options),
208
- (change) => `Image is now the ${destination.label.toLowerCase()}. ${lossText(change)}`,
209
- );
210
- reselect(`slides.${state.slideIndex}`);
211
- },
212
- });
213
- arrange.append(group.wrap, ungroup.wrap, image.wrap);
263
+ const host = ctx.blockActions ?? {};
264
+ const before = (host.before ?? []).map(hostAction);
265
+ const after = (host.after ?? []).map(hostAction);
266
+ const separator = () => h("span", { class: "opf-dc-toolbar-separator", "aria-hidden": "true" });
267
+ arrange.append(...before.map((entry) => entry.wrap), ...(before.length ? [separator()] : []), group.wrap, ungroup.wrap, ...(after.length ? [separator()] : []), ...after.map((entry) => entry.wrap));
214
268
  updaters.push((state) => {
215
- arrange.hidden = !state.content;
269
+ arrange.hidden = !state.content && !state.groupPath;
216
270
  if (arrange.hidden) return;
217
- group.update(state);
218
- ungroup.update(state);
271
+ for (const entry of [...before, group, ungroup, ...after]) entry.update(state);
272
+ });
273
+ updaters.push((state) => {
219
274
  const isImage = state.imageBlock === true;
220
- image.wrap.hidden = !isImage;
221
- if (isImage) {
222
- image.setOptions(
223
- IMAGE_DESTINATIONS.map((entry) => {
224
- try {
225
- const prepared = prepareImageToDesign(editor.presentation, state.blockPath, entry.target, { ...entry.options, validate: false });
226
- return { value: entry.value, label: `${entry.label}${prepared.loss.length ? ` (loses ${prepared.loss.join(", ")})` : ""}` };
227
- } catch (error) {
228
- return { value: entry.value, label: `${entry.label} (unavailable)`, disabled: true, title: messageOf(error) };
229
- }
230
- }),
231
- );
232
- image.set("");
233
- }
275
+ imageUse.wrap.hidden = !isImage;
276
+ if (!isImage) return;
277
+ imageUse.setOptions(
278
+ IMAGE_DESTINATIONS.map((entry) => {
279
+ try {
280
+ const prepared = prepareImageToDesign(editor.presentation, state.blockPath, entry.target, { ...entry.options, validate: false });
281
+ return { value: entry.value, label: `${entry.label}${prepared.loss.length ? ` (loses ${prepared.loss.join(", ")})` : ""}` };
282
+ } catch (error) {
283
+ return { value: entry.value, label: entry.label, disabled: true, title: shortReason(messageOf(error)) };
284
+ }
285
+ }),
286
+ );
287
+ imageUse.set("");
234
288
  });
235
289
  }
236
290
 
@@ -238,14 +292,13 @@ export function createContentControls(ctx) {
238
292
 
239
293
  const slideBox = h("div", { class: "opf-dc-actions-block", "data-role": "slide-structure" });
240
294
  {
241
- const layouts = selectField("region-layout", "Place the blocks in regions", {
295
+ const layouts = selectField("region-layout", "Place blocks in regions", {
242
296
  empty: "Choose a layout…",
243
- help: "Each block gets its own part of the slide. Undo returns them to one list.",
244
297
  onChange: (value) => {
245
298
  const slideIndex = getSlide();
246
299
  const choice = REGION_LAYOUTS[blockCount(slideIndex)]?.find((entry) => entry.id === value);
247
300
  if (!choice) return;
248
- run(() => placeBlocksInRegions(editor, slideIndex, choice.regions), (change) => `Blocks placed: ${choice.label.toLowerCase()}. ${lossText(change)}`);
301
+ run(() => placeBlocksInRegions(editor, slideIndex, choice.regions), (change) => `Blocks placed: ${choice.label.toLowerCase()}.${lossText(change) ? ` ${lossText(change)}` : ""}`);
249
302
  reselect(`slides.${slideIndex}`);
250
303
  },
251
304
  });
@@ -278,7 +331,7 @@ export function createContentControls(ctx) {
278
331
  const prepared = prepareBlocksToRegions(editor.presentation, state.slideIndex, choice.regions, { validate: false });
279
332
  return { value: choice.id, label: `${choice.label}${prepared.loss.length ? ` (loses ${prepared.loss.join(", ")})` : ""}` };
280
333
  } catch (error) {
281
- return { value: choice.id, label: `${choice.label} (unavailable)`, disabled: true, title: messageOf(error) };
334
+ return { value: choice.id, label: choice.label, disabled: true, title: shortReason(messageOf(error)) };
282
335
  }
283
336
  }),
284
337
  );
@@ -303,8 +356,8 @@ export function createContentControls(ctx) {
303
356
  }
304
357
 
305
358
  return {
306
- /** Nodes for the "Selected content" section: list levels, the group-of-metrics conversion and the arrange actions. */
307
- nodes: { list, metrics: metrics.wrap, arrange },
359
+ /** Nodes for the "Selected content" section: the arrange toolbar, list levels, the group-of-metrics conversion and the image destination. */
360
+ nodes: { arrange, list, metrics: metrics.wrap, image: imageUse.wrap },
308
361
  /** The node for the "Slide structure" section. */
309
362
  slideNode: slideBox,
310
363
  /** Update every control from the session and the current selection. */
package/dist/data-grid.js CHANGED
@@ -1120,6 +1120,19 @@ export function createDataGrid(container, options = {}) {
1120
1120
  const td = event.target.closest?.("td[data-line]");
1121
1121
  return td ? { u: Number(td.dataset.line), c: Number(td.dataset.column) } : undefined;
1122
1122
  };
1123
+ // A press focuses a cell (or the table) at once. A host that was editing text elsewhere (the slide canvas) saves it when the grid takes
1124
+ // the focus, and may move the focus back as it does (the canvas focuses the target it saved, from its field's blur), so the grid takes it
1125
+ // once more. Both happen inside the press: a key pressed straight after the click must reach the grid, and a timer would run after it
1126
+ // (browsers dispatch pending input before timers). The timer stays for a host that moves the focus later.
1127
+ const focusFromPress = (at) => {
1128
+ const take = () => (cellNode(at.u, at.c) ?? table).focus?.({ preventScroll: true });
1129
+ const lost = () => !destroyed && !editing && grid && !root.contains(doc.activeElement);
1130
+ take();
1131
+ if (lost()) take();
1132
+ setTimeout(() => {
1133
+ if (lost()) take();
1134
+ }, 0);
1135
+ };
1123
1136
  table.addEventListener("mousedown", (event) => {
1124
1137
  if (!grid || event.button !== 0) return;
1125
1138
  const ruler = event.target.closest?.("th[data-ruler]");
@@ -1139,7 +1152,7 @@ export function createDataGrid(container, options = {}) {
1139
1152
  if (event.shiftKey) selection.anchor = { u: selection.anchor.u, c: 0 };
1140
1153
  }
1141
1154
  paint();
1142
- (cellNode(selection.focus.u, selection.focus.c) ?? table).focus?.({ preventScroll: true });
1155
+ focusFromPress({ ...selection.focus });
1143
1156
  return;
1144
1157
  }
1145
1158
  const hit = cellFromEvent(event);
@@ -1154,12 +1167,7 @@ export function createDataGrid(container, options = {}) {
1154
1167
  dragging = true;
1155
1168
  invalid = new Map();
1156
1169
  setSelection(event.shiftKey ? selection.anchor : hit, hit, { scroll: false });
1157
- // A host that was editing text elsewhere (the slide canvas) saves it when this click takes the focus, and may move the focus back as it does.
1158
- const wanted = { ...selection.focus };
1159
- setTimeout(() => {
1160
- if (destroyed || editing || !grid || root.contains(doc.activeElement)) return;
1161
- cellNode(wanted.u, wanted.c)?.focus({ preventScroll: true });
1162
- }, 0);
1170
+ focusFromPress({ ...selection.focus });
1163
1171
  });
1164
1172
  table.addEventListener("mouseover", (event) => {
1165
1173
  if (!dragging) return;
@@ -37,6 +37,19 @@ export interface DesignControlsOptions {
37
37
  onAddAsset?: (image: { name: string; mediaType: string; bytes: Uint8Array; size: number; alt?: string; file: unknown }) => string | { src: string } | Promise<string | { src: string }>;
38
38
  /** Called with every status or error message the panel shows in its live regions. */
39
39
  onStatus?: (message: string, info: { error: boolean }) => void;
40
+ /** Show the selected block's path as a copyable chip under the selection section's Advanced (default true). A host with its own Advanced turns it off. */
41
+ showPath?: boolean;
42
+ /** Host actions for the selected block's arrange toolbar: `before` Group with the next block and Ungroup (Move up, Move down), `after` them (Duplicate, Delete). */
43
+ blockActions?: { before?: readonly DesignBlockAction[]; after?: readonly DesignBlockAction[] };
44
+ }
45
+ /** One icon button of the arrange toolbar, supplied by the host. Its tooltip is the label and shortcut, or the label and the reason when it is disabled. */
46
+ export interface DesignBlockAction {
47
+ role: string;
48
+ label: string;
49
+ /** An icon name of the studio kit's set (`ArrowUp`, `ArrowDown`, `Copy`, `Trash2`, ...). */
50
+ icon: string;
51
+ state(): { enabled: boolean; reason?: string; shortcut?: string };
52
+ run(): void;
40
53
  }
41
54
  export interface DesignControls {
42
55
  element: HTMLElement;