@simai-studio/ui 5.20261023.212 → 5.20261025.207

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 (50) hide show
  1. package/README.md +4 -4
  2. package/distr/component/button-group/css/button-group.min.css +1 -1
  3. package/distr/core/contracts/public-variables.v1.json +88 -0
  4. package/distr/core/contracts/third-party-notices.v1.json +1 -1
  5. package/distr/core/css/core.css +3 -2
  6. package/distr/core/js/composition/overlay.mjs +472 -24
  7. package/distr/core/js/composition/sortable-inserter.mjs +534 -0
  8. package/distr/core/js/core-loader.js +1 -1
  9. package/distr/core/js/core-loader.min.js +1 -1
  10. package/distr/core/js/core-rules.js +242 -185
  11. package/distr/core/js/core-rules.min.js +1 -1
  12. package/distr/core/js/open-surface-mark.js +88 -0
  13. package/distr/core/js/template-utilities.generated.json +3 -0
  14. package/distr/rule/js/rule.min.js +1 -1
  15. package/distr/rule/rule.json +47 -1
  16. package/distr/smart/color/css/color.min.css +1 -0
  17. package/distr/smart/color/js/color.min.js +36 -17
  18. package/distr/smart/color/smart.manifest.json +45 -4
  19. package/distr/smart/color/template/default.js +13 -2
  20. package/distr/smart/composition-overlay/css/composition-overlay.min.css +1 -1
  21. package/distr/smart/composition-overlay/js/composition-overlay.min.js +1 -1
  22. package/distr/smart/composition-overlay/smart.manifest.json +178 -11
  23. package/distr/smart/dropdown/css/dropdown.min.css +1 -0
  24. package/distr/smart/dropdown/js/dropdown.min.js +41 -21
  25. package/distr/smart/dropdown/smart.manifest.json +62 -3
  26. package/distr/smart/dropdown/template/default.js +5 -1
  27. package/distr/smart/field-renderers/js/field-renderers.min.js +54 -42
  28. package/distr/smart/image-field/css/image-field.min.css +1 -0
  29. package/distr/smart/image-field/js/image-field.min.js +62 -0
  30. package/distr/smart/image-field/smart.manifest.json +519 -0
  31. package/distr/smart/image-field/studio.fixture.json +15 -0
  32. package/distr/smart/image-field/studio.scenario.json +22 -0
  33. package/distr/smart/image-field/template/default.js +125 -0
  34. package/distr/smart/inputs/css/inputs.min.css +1 -0
  35. package/distr/smart/inputs/js/inputs.min.js +93 -75
  36. package/distr/smart/inputs/smart.manifest.json +62 -3
  37. package/distr/smart/inputs/template/default.js +8 -2
  38. package/distr/smart/sortable/css/sortable.min.css +1 -1
  39. package/distr/smart/sortable/js/sortable.min.js +1 -1
  40. package/distr/smart/sortable/smart.manifest.json +101 -14
  41. package/distr/smart/switch/css/switch.min.css +1 -0
  42. package/distr/smart/switch/js/switch.min.js +31 -13
  43. package/distr/smart/switch/smart.manifest.json +62 -3
  44. package/distr/smart/switch/template/default.js +2 -0
  45. package/distr/smart/textarea/css/textarea.min.css +1 -0
  46. package/distr/smart/textarea/js/textarea.min.js +51 -32
  47. package/distr/smart/textarea/smart.manifest.json +62 -3
  48. package/distr/smart/textarea/template/default.js +8 -1
  49. package/package-manifest.json +50 -39
  50. package/package.json +4 -4
@@ -1,22 +1,29 @@
1
1
  import { isPlainObject } from './canonical.mjs';
2
2
  import { pageText } from '../page-language.js';
3
3
  import { activeSortableSession, registerSortableDropTarget } from './sortable.mjs';
4
+ import { holdOpenSurfaceMark, isTopOpenSurface } from '../open-surface-mark.js';
4
5
 
5
6
  // Editor canvas overlay. It draws hover and selection frames, a floating
6
7
  // action toolbar and insertion points over a rendered Composition Document
7
8
  // without touching the canvas DOM: the published HTML stays byte-identical.
8
9
  // The host supplies node descriptions and actions and performs every change.
10
+ //
11
+ // Framed mode (attachFrame + setNodes) draws the same chrome over a same-origin
12
+ // iframe fed a flat node list: frame coordinates are mapped to the host page
13
+ // through the iframe's position, borders, padding, scale and the frame's own
14
+ // scroll, and a plain-string inline edit happens on the element inside the frame.
9
15
 
10
16
  export const OVERLAY_EVENTS = Object.freeze({
11
17
  select: 'sf-composition-overlay-select',
12
18
  action: 'sf-composition-overlay-action',
13
19
  insert: 'sf-composition-overlay-insert',
14
20
  move: 'sf-composition-overlay-move',
21
+ edit: 'sf-composition-overlay-edit',
15
22
  });
16
23
 
17
24
  const MESSAGES = {
18
- ru: { picked: 'Перемещение: {label}. Стрелки выбирают место, Enter — положить, Escape — отменить.', cancelled: 'Перемещение отменено.', tree: 'Структура страницы', toolbar: 'Действия', insert: 'Вставить', insertAt: 'Вставить в «{slot}», позиция {index}', position: '{index} из {total}', selected: 'Выбрано: {label}' },
19
- en: { picked: 'Moving {label}. Arrows choose a place, Enter drops, Escape cancels.', cancelled: 'Move cancelled.', tree: 'Page structure', toolbar: 'Actions', insert: 'Insert', insertAt: 'Insert into “{slot}”, position {index}', position: '{index} of {total}', selected: 'Selected: {label}' },
25
+ ru: { picked: 'Перемещение: {label}. Стрелки выбирают место, Enter — положить, Escape — отменить.', cancelled: 'Перемещение отменено.', tree: 'Структура страницы', toolbar: 'Действия', insert: 'Вставить', insertAt: 'Вставить в «{slot}», позиция {index}', position: '{index} из {total}', selected: 'Выбрано: {label}', editing: 'Редактирование: {label}. Enter — сохранить, Escape — отменить.' },
26
+ en: { picked: 'Moving {label}. Arrows choose a place, Enter drops, Escape cancels.', cancelled: 'Move cancelled.', tree: 'Page structure', toolbar: 'Actions', insert: 'Insert', insertAt: 'Insert into “{slot}”, position {index}', position: '{index} of {total}', selected: 'Selected: {label}', editing: 'Editing {label}. Enter saves, Escape cancels.' },
20
27
  };
21
28
 
22
29
  const ACTION_ID = /^[a-z][a-z0-9-]{0,63}$/u;
@@ -50,6 +57,114 @@ export function checkOverlayActions(actions) {
50
57
  });
51
58
  }
52
59
 
60
+ const NODE_KEYS = new Set(['id', 'kind', 'rect', 'element', 'label', 'parent', 'editable']);
61
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value);
62
+ // Elements of the frame belong to another realm, so `instanceof Element` is false for them.
63
+ const isElement = (value) => Boolean(value) && typeof value === 'object' && value.nodeType === 1 && typeof value.getBoundingClientRect === 'function';
64
+
65
+ /**
66
+ * Checks a framed canvas's flat node list: [{id, kind, rect? | element?, label?,
67
+ * parent?, editable?}]. rect is in frame-document coordinates {x, y, width,
68
+ * height}; element is a node inside the frame. Returns frozen entries.
69
+ */
70
+ export function checkOverlayNodes(nodes) {
71
+ if (!Array.isArray(nodes)) throw new TypeError('Overlay nodes must be a list');
72
+ const seen = new Set();
73
+ const list = nodes.map((node) => {
74
+ if (!isPlainObject(node) || Object.keys(node).some((key) => !NODE_KEYS.has(key))) {
75
+ throw new TypeError('Overlay nodes have id, kind, rect or element, label, parent and editable only');
76
+ }
77
+ if (typeof node.id !== 'string' || !node.id || seen.has(node.id)) throw new TypeError('Overlay nodes have unique non-empty string ids');
78
+ if (typeof node.kind !== 'string' || !node.kind) throw new TypeError(`Overlay node ${node.id} names its kind`);
79
+ const { rect, element } = node;
80
+ if (rect !== undefined && (!isPlainObject(rect) || !['x', 'y', 'width', 'height'].every((key) => finite(rect[key])) || rect.width < 0 || rect.height < 0)) {
81
+ throw new TypeError(`Overlay node ${node.id} has a rect {x, y, width, height} of finite numbers`);
82
+ }
83
+ if (element !== undefined && !isElement(element)) throw new TypeError(`Overlay node ${node.id} has an element`);
84
+ if (rect === undefined && element === undefined) throw new TypeError(`Overlay node ${node.id} has a rect or an element`);
85
+ if (node.label !== undefined && typeof node.label !== 'string') throw new TypeError(`Overlay node ${node.id} has a string label`);
86
+ if (node.parent !== undefined && node.parent !== null && (typeof node.parent !== 'string' || node.parent === node.id)) {
87
+ throw new TypeError(`Overlay node ${node.id} names another node as its parent`);
88
+ }
89
+ if (node.editable !== undefined && typeof node.editable !== 'boolean') throw new TypeError(`Overlay node ${node.id} has a boolean editable`);
90
+ if (node.editable && element === undefined) throw new TypeError(`Overlay node ${node.id} is editable only with an element`);
91
+ seen.add(node.id);
92
+ return Object.freeze({
93
+ id: node.id,
94
+ kind: node.kind,
95
+ rect: rect === undefined ? null : Object.freeze({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }),
96
+ element: element ?? null,
97
+ label: node.label ?? null,
98
+ parent: node.parent ?? null,
99
+ editable: node.editable === true,
100
+ });
101
+ });
102
+ const byId = new Map(list.map((node) => [node.id, node]));
103
+ for (const node of list) {
104
+ const visited = new Set([node.id]);
105
+ for (let parent = node.parent; parent !== null; parent = byId.get(parent).parent) {
106
+ if (!byId.has(parent)) throw new TypeError(`Overlay node ${node.id} names a parent that is not in the list`);
107
+ if (visited.has(parent)) throw new TypeError(`Overlay node ${node.id} is its own ancestor`);
108
+ visited.add(parent);
109
+ }
110
+ }
111
+ return Object.freeze(list);
112
+ }
113
+
114
+ /** Overlay entries for a checked flat list, in list order; the top level is parent null, slot "default". */
115
+ export function flatOverlayNodes(list) {
116
+ const entries = list.map((node) => ({ id: node.id, type: node.kind, kind: node.kind, label: node.label, parent: node.parent, slot: 'default', index: 0, level: 1, slots: {}, flat: node }));
117
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
118
+ const counts = new Map();
119
+ for (const entry of entries) {
120
+ const key = entry.parent ?? '';
121
+ entry.index = counts.get(key) || 0;
122
+ counts.set(key, entry.index + 1);
123
+ if (entry.parent) (byId.get(entry.parent).slots.default ||= []).push(entry.id);
124
+ for (let parent = entry.parent; parent; parent = byId.get(parent).parent) entry.level += 1;
125
+ }
126
+ return entries;
127
+ }
128
+
129
+ /**
130
+ * Where a frame's viewport lies on the host page. rect is the iframe's border box
131
+ * on the host (after any transform or zoom), layout its untransformed border box
132
+ * size (offsetWidth/offsetHeight), border and padding its own {top, right,
133
+ * bottom, left} in CSS px, inner the frame's viewport size in its own px and
134
+ * scroll the frame's scroll position. The scale is rect over layout; without a
135
+ * layout size it is taken from the parts, which the viewport's whole-pixel size
136
+ * makes less exact under zoom.
137
+ */
138
+ export function frameViewport({ rect, layout = null, border, padding, inner, scroll = { x: 0, y: 0 } }) {
139
+ const outerWidth = layout?.width > 0 ? layout.width : border.left + padding.left + inner.width + padding.right + border.right;
140
+ const outerHeight = layout?.height > 0 ? layout.height : border.top + padding.top + inner.height + padding.bottom + border.bottom;
141
+ const scaleX = outerWidth > 0 && rect.width > 0 ? rect.width / outerWidth : 1;
142
+ const scaleY = outerHeight > 0 && rect.height > 0 ? rect.height / outerHeight : 1;
143
+ return {
144
+ left: rect.left + (border.left + padding.left) * scaleX,
145
+ top: rect.top + (border.top + padding.top) * scaleY,
146
+ width: inner.width * scaleX,
147
+ height: inner.height * scaleY,
148
+ scaleX,
149
+ scaleY,
150
+ scrollX: scroll.x,
151
+ scrollY: scroll.y,
152
+ };
153
+ }
154
+
155
+ /** A box in the frame's viewport coordinates {x, y, width, height}, mapped to the host page. */
156
+ export function frameRectToHost(box, view) {
157
+ const left = view.left + box.x * view.scaleX;
158
+ const top = view.top + box.y * view.scaleY;
159
+ const width = box.width * view.scaleX;
160
+ const height = box.height * view.scaleY;
161
+ return { x: left, y: top, left, top, width, height, right: left + width, bottom: top + height };
162
+ }
163
+
164
+ const plainText = (element) => (element.textContent || '').replace(/\s+/gu, ' ').trim();
165
+ const px = (value) => Number.parseFloat(value) || 0;
166
+ const intersect = (a, b) => ({ left: Math.max(a.left, b.left), top: Math.max(a.top, b.top), right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom) });
167
+
53
168
  export function defineCompositionOverlay(registry = globalThis.customElements) {
54
169
  if (!registry || typeof globalThis.HTMLElement !== 'function') return null;
55
170
  const existing = registry.get('sf-composition-overlay');
@@ -74,6 +189,13 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
74
189
  this.moving = null;
75
190
  this.unregisterDrop = null;
76
191
  this.dropHint = null;
192
+ this.iframe = null;
193
+ this.frameDoc = null;
194
+ this.frameConnection = null;
195
+ this.view = null;
196
+ this.elementIds = new Map();
197
+ this.rectNodes = [];
198
+ this.editing = null;
77
199
  }
78
200
 
79
201
  get messages() {
@@ -85,10 +207,14 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
85
207
  connectedCallback() {
86
208
  if (!this.layer) this.buildLayer();
87
209
  const canvasId = this.getAttribute('for');
88
- if (canvasId && !this.canvas) {
210
+ const frameId = this.getAttribute('frame');
211
+ if (frameId && !this.canvas && !this.iframe) {
212
+ const iframe = globalThis.document.getElementById(frameId);
213
+ if (iframe) this.attachFrame(iframe);
214
+ } else if (canvasId && !this.canvas && !this.iframe) {
89
215
  const canvas = globalThis.document.getElementById(canvasId);
90
216
  if (canvas) this.attach(canvas);
91
- } else if (this.canvas) {
217
+ } else if (this.canvas || this.iframe) {
92
218
  this.bind();
93
219
  }
94
220
  }
@@ -138,18 +264,40 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
138
264
  if (!this.layer) this.buildLayer();
139
265
  this.unbind();
140
266
  this.canvas = canvas;
267
+ this.iframe = null;
268
+ this.removeAttribute('data-sf-overlay-mode');
269
+ this.layer.style.clipPath = '';
270
+ this.insertionLayer.style.clipPath = '';
271
+ if (this.isConnected) this.bind();
272
+ return this;
273
+ }
274
+
275
+ /** Binds the overlay to a same-origin iframe canvas fed by setNodes; the frame's HTML is never modified by the overlay. */
276
+ attachFrame(iframe) {
277
+ if (!isElement(iframe) || iframe.localName !== 'iframe') throw new TypeError('Overlay frame must be an iframe element');
278
+ if (!this.layer) this.buildLayer();
279
+ this.unbind();
280
+ this.canvas = null;
281
+ this.iframe = iframe;
282
+ this.setAttribute('data-sf-overlay-mode', 'frame');
141
283
  if (this.isConnected) this.bind();
142
284
  return this;
143
285
  }
144
286
 
145
287
  bind() {
146
- if (this.connection || !this.canvas) return;
288
+ if (this.connection || (!this.canvas && !this.iframe)) return;
147
289
  this.connection = new AbortController();
148
290
  const options = { signal: this.connection.signal };
149
291
  const passive = { ...options, passive: true };
150
- this.canvas.addEventListener('pointermove', (event) => this.onPointerMove(event), passive);
151
- this.canvas.addEventListener('pointerleave', () => this.setHover(null), passive);
152
- this.canvas.addEventListener('click', (event) => this.onCanvasClick(event), { ...options, capture: true });
292
+ if (this.iframe) {
293
+ // A new letter (srcdoc, navigation) is a new document: listen to it again.
294
+ this.iframe.addEventListener('load', () => this.bindFrameDocument(), options);
295
+ this.iframe.addEventListener('pointerleave', () => this.setHover(null), passive);
296
+ } else {
297
+ this.canvas.addEventListener('pointermove', (event) => this.onPointerMove(event), passive);
298
+ this.canvas.addEventListener('pointerleave', () => this.setHover(null), passive);
299
+ this.canvas.addEventListener('click', (event) => this.onCanvasClick(event), { ...options, capture: true });
300
+ }
153
301
  this.tree.addEventListener('keydown', (event) => this.onTreeKey(event), options);
154
302
  this.tree.addEventListener('focus', () => {
155
303
  this.setAttribute('data-sf-overlay-focus', '');
@@ -163,7 +311,7 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
163
311
  globalThis.addEventListener('resize', () => this.schedule(), passive);
164
312
  if (typeof globalThis.ResizeObserver === 'function') {
165
313
  this.resizeObserver = new globalThis.ResizeObserver(() => this.schedule());
166
- this.resizeObserver.observe(this.canvas);
314
+ this.resizeObserver.observe(this.canvas || this.iframe);
167
315
  }
168
316
  this.unregisterDrop = registerSortableDropTarget({
169
317
  resolve: (x, y, dragSession) => (this.accepts(dragSession) ? this.resolveDrop(x, y, null) : null),
@@ -174,10 +322,56 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
174
322
  this.selectedFrame.firstChild.addEventListener('pointerdown', (event) => this.onHandleDown(event), options);
175
323
  this.setAttribute('data-sf-overlay', 'connected');
176
324
  this.renderTree();
325
+ if (this.iframe) this.bindFrameDocument();
326
+ this.schedule();
327
+ }
328
+
329
+ /** Listens to the frame's current document and window; called on bind and on every frame load. */
330
+ bindFrameDocument() {
331
+ if (this.editing) this.finishEdit(false);
332
+ this.frameConnection?.abort();
333
+ this.frameConnection = null;
334
+ let doc = null;
335
+ try {
336
+ doc = this.iframe.contentDocument;
337
+ } catch {
338
+ doc = null;
339
+ }
340
+ const win = doc?.defaultView || null;
341
+ this.frameDoc = win ? doc : null;
342
+ if (this.resizeObserver) {
343
+ this.resizeObserver.disconnect();
344
+ this.resizeObserver.observe(this.iframe);
345
+ }
346
+ if (!this.frameDoc) {
347
+ // A cross-origin or not yet available frame: nothing is drawn over it.
348
+ this.setAttribute('data-sf-overlay-frame', 'unreachable');
349
+ this.schedule();
350
+ return;
351
+ }
352
+ this.removeAttribute('data-sf-overlay-frame');
353
+ this.frameConnection = new AbortController();
354
+ const options = { signal: this.frameConnection.signal };
355
+ const passive = { ...options, passive: true };
356
+ doc.addEventListener('pointermove', (event) => this.setHover(this.frameNodeAt(event)), passive);
357
+ doc.addEventListener('pointerout', (event) => { if (!event.relatedTarget) this.setHover(null); }, passive);
358
+ doc.addEventListener('click', (event) => this.onFrameClick(event), { ...options, capture: true });
359
+ doc.addEventListener('dblclick', (event) => this.onFrameDoubleClick(event), { ...options, capture: true });
360
+ doc.addEventListener('keydown', (event) => this.onFrameKey(event), { ...options, capture: true });
361
+ win.addEventListener('scroll', () => this.schedule(), { ...passive, capture: true });
362
+ win.addEventListener('resize', () => this.schedule(), passive);
363
+ if (this.resizeObserver) {
364
+ if (doc.documentElement) this.resizeObserver.observe(doc.documentElement);
365
+ if (doc.body) this.resizeObserver.observe(doc.body);
366
+ }
177
367
  this.schedule();
178
368
  }
179
369
 
180
370
  unbind() {
371
+ this.finishEdit(false);
372
+ this.frameConnection?.abort();
373
+ this.frameConnection = null;
374
+ this.frameDoc = null;
181
375
  this.pointerDrag?.abort();
182
376
  this.pointerDrag = null;
183
377
  this.unregisterDrop?.();
@@ -189,15 +383,40 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
189
383
  this.resizeObserver = null;
190
384
  if (this.frameRequest) globalThis.cancelAnimationFrame(this.frameRequest);
191
385
  this.frameRequest = 0;
192
- if (this.canvas) this.setAttribute('data-sf-overlay', 'disposed');
386
+ if (this.canvas || this.iframe) this.setAttribute('data-sf-overlay', 'disposed');
193
387
  }
194
388
 
195
389
  /** Supplies the Document structure and a describe(node) → {label, badges}. */
196
390
  setDocument(document, { describe } = {}) {
391
+ this.elementIds = new Map();
392
+ this.rectNodes = [];
393
+ this.replaceNodes(overlayNodes(document), describe);
394
+ return this;
395
+ }
396
+
397
+ /**
398
+ * Supplies a framed canvas's flat node list [{id, kind, rect? | element?,
399
+ * label?, parent?, editable?}] in selection order, and an optional
400
+ * describe(node) → {label, badges}; without it the label is node.label or its kind.
401
+ */
402
+ setNodes(nodes, { describe } = {}) {
403
+ const list = checkOverlayNodes(nodes);
404
+ this.elementIds = new Map(list.filter((node) => node.element).map((node) => [node.element, node.id]));
405
+ const entries = flatOverlayNodes(list);
406
+ this.rectNodes = entries.filter((entry) => entry.flat.rect && !entry.flat.element);
407
+ if (this.editing) {
408
+ const next = list.find((node) => node.id === this.editing.id);
409
+ if (!next || next.element !== this.editing.element || !next.editable) this.finishEdit(false);
410
+ }
411
+ this.replaceNodes(entries, typeof describe === 'function' ? describe : (node) => ({ label: node.label || node.kind }));
412
+ return this;
413
+ }
414
+
415
+ replaceNodes(nodes, describe) {
197
416
  const previous = this.selectedId ? this.byId.get(this.selectedId) : null;
198
417
  const active = globalThis.document.activeElement;
199
418
  const focused = Boolean(active) && (this.toolbar?.contains(active) || this.insertionLayer?.contains(active));
200
- this.nodes = overlayNodes(document);
419
+ this.nodes = nodes;
201
420
  this.byId = new Map(this.nodes.map((node) => [node.id, node]));
202
421
  if (typeof describe === 'function') this.describe = describe;
203
422
  this.renderTree();
@@ -210,6 +429,11 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
210
429
  this.select(focused ? fallback : null, { focus: focused });
211
430
  }
212
431
  this.schedule();
432
+ }
433
+
434
+ /** Re-measures on the next frame, after a change no observer reports (a transform on an ancestor of the canvas). */
435
+ refresh() {
436
+ this.schedule();
213
437
  return this;
214
438
  }
215
439
 
@@ -345,6 +569,7 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
345
569
 
346
570
  // Moving a canvas node: pointer via the selected label, keyboard via the tree.
347
571
  onHandleDown(event) {
572
+ if (this.iframe) return;
348
573
  if (event.button !== 0 || !this.selectedId || !this.byId.get(this.selectedId)?.parent) return;
349
574
  event.preventDefault();
350
575
  const moving = this.selectedId;
@@ -434,6 +659,7 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
434
659
 
435
660
  select(id, { focus = false, emit = true } = {}) {
436
661
  if (id !== null && !this.byId.has(id)) return this;
662
+ if (this.editing && this.editing.id !== id) this.finishEdit(true);
437
663
  if (this.selectedId === id) return this;
438
664
  this.selectedId = id;
439
665
  this.renderTreeSelection();
@@ -481,7 +707,7 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
481
707
 
482
708
  onTreeKey(event) {
483
709
  if (this.moving && this.onMoveKey(event)) return;
484
- if (event.key === ' ' && this.selectedId) {
710
+ if (event.key === ' ' && this.selectedId && !this.iframe) {
485
711
  event.preventDefault();
486
712
  this.startKeyboardMove();
487
713
  return;
@@ -495,10 +721,14 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
495
721
  else if (event.key === 'End') next = this.nodes.at(-1);
496
722
  else if (event.key === 'ArrowLeft' && current?.parent) next = this.byId.get(current.parent);
497
723
  else if (event.key === 'ArrowRight' && current) next = this.nodes.find((node) => node.parent === current.id) || null;
498
- else if (event.key === 'Escape' && this.selectedId !== null) {
724
+ else if (event.key === 'Escape' && this.selectedId !== null && (!this.iframe || isTopOpenSurface(this))) {
499
725
  event.preventDefault();
500
726
  this.select(null);
501
727
  return;
728
+ } else if (event.key === 'Enter' && this.iframe && current?.flat?.editable) {
729
+ event.preventDefault();
730
+ this.edit(current.id);
731
+ return;
502
732
  } else if (event.key === 'Enter' && this.toolbar.querySelector('button')) {
503
733
  event.preventDefault();
504
734
  this.toolbar.querySelector('button').focus();
@@ -590,13 +820,29 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
590
820
  });
591
821
  }
592
822
 
823
+ /** A node's box on the host page: its element's rect, or in framed mode its frame box mapped out of the frame. */
824
+ nodeBox(id) {
825
+ const node = typeof id === 'string' ? this.byId.get(id) : null;
826
+ if (!node) return null;
827
+ if (!node.flat) return this.element(id)?.getBoundingClientRect() ?? null;
828
+ const view = this.view;
829
+ if (!view || !this.frameDoc) return null;
830
+ const { element, rect } = node.flat;
831
+ if (element) {
832
+ if (!element.isConnected || element.ownerDocument !== this.frameDoc) return null;
833
+ const box = element.getBoundingClientRect();
834
+ if (!box.width && !box.height) return null;
835
+ return frameRectToHost({ x: box.left, y: box.top, width: box.width, height: box.height }, view);
836
+ }
837
+ return frameRectToHost({ x: rect.x - view.scrollX, y: rect.y - view.scrollY, width: rect.width, height: rect.height }, view);
838
+ }
839
+
593
840
  place(frame, id) {
594
- const element = id ? this.element(id) : null;
595
- if (!element) {
841
+ const box = id ? this.nodeBox(id) : null;
842
+ if (!box) {
596
843
  frame.hidden = true;
597
844
  return null;
598
845
  }
599
- const box = element.getBoundingClientRect();
600
846
  frame.hidden = false;
601
847
  frame.style.insetInlineStart = '';
602
848
  Object.assign(frame.style, { left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` });
@@ -606,9 +852,18 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
606
852
  }
607
853
 
608
854
  position() {
609
- if (!this.canvas) return;
855
+ if (!this.canvas && !this.iframe) return;
856
+ if (this.iframe) {
857
+ this.view = this.frameMetrics();
858
+ this.applyClip(this.view?.clip ?? null);
859
+ }
610
860
  this.place(this.hoverFrame, this.hoverId && this.hoverId !== this.selectedId ? this.hoverId : null);
611
861
  const box = this.place(this.selectedFrame, this.selectedId);
862
+ if (this.iframe) {
863
+ this.positionFrameToolbar(box);
864
+ this.positionInsertions();
865
+ return;
866
+ }
612
867
  // No toolbar without a drawn selection, e.g. while the host re-renders,
613
868
  // unless it holds keyboard focus.
614
869
  if (box) this.toolbar.hidden = this.actions.length === 0;
@@ -627,13 +882,13 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
627
882
 
628
883
  positionInsertions() {
629
884
  const selected = this.selectedId ? this.byId.get(this.selectedId) : null;
630
- const points = [];
631
- if (selected?.parent) {
885
+ const points = selected?.flat ? this.flatInsertionPoints(selected) : [];
886
+ if (selected?.parent && !selected.flat) {
632
887
  const parent = this.byId.get(selected.parent);
633
888
  const siblings = parent.slots[selected.slot] || [];
634
889
  for (let index = 0; index <= siblings.length; index += 1) points.push({ parent: parent.id, slot: selected.slot, index, before: siblings[index], after: siblings[index - 1] });
635
890
  }
636
- if (selected) {
891
+ if (selected && !selected.flat) {
637
892
  for (const [slot, children] of Object.entries(selected.slots)) points.push({ parent: selected.id, slot, index: children.length, after: children.at(-1), end: true });
638
893
  }
639
894
  const target = this.insertionTarget;
@@ -641,8 +896,8 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
641
896
  const existing = new Map([...this.insertionLayer.children].map((button) => [button.dataset.key, button]));
642
897
  const keep = new Set();
643
898
  for (const point of points) {
644
- const anchor = this.element(point.before) || this.element(point.after) || this.element(point.parent);
645
- if (!anchor) continue;
899
+ const box = this.nodeBox(point.before) || this.nodeBox(point.after) || this.nodeBox(point.parent);
900
+ if (!box) continue;
646
901
  const key = `${point.parent}|${point.slot}|${point.index}`;
647
902
  keep.add(key);
648
903
  let button = existing.get(key);
@@ -661,7 +916,6 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
661
916
  button.append(mark);
662
917
  this.insertionLayer.append(button);
663
918
  }
664
- const box = anchor.getBoundingClientRect();
665
919
  const top = point.before && !point.end ? box.top : box.bottom;
666
920
  const marked = Boolean(target && target.parent_id === point.parent && target.slot === point.slot && target.index === point.index);
667
921
  button.setAttribute('aria-pressed', marked ? 'true' : 'false');
@@ -670,10 +924,204 @@ export function defineCompositionOverlay(registry = globalThis.customElements) {
670
924
  }
671
925
  for (const [key, button] of existing) if (!keep.has(key)) button.remove();
672
926
  }
927
+
928
+ // Framed mode: insertion places among the selected node's siblings (the top
929
+ // level is parent_id ""), and at the end of its own children; setDropFilter hides places.
930
+ flatInsertionPoints(selected) {
931
+ const siblings = this.nodes.filter((node) => node.parent === selected.parent).map((node) => node.id);
932
+ const parent = selected.parent ?? '';
933
+ const points = [];
934
+ for (let index = 0; index <= siblings.length; index += 1) points.push({ parent, slot: 'default', index, before: siblings[index], after: siblings[index - 1] });
935
+ const children = selected.slots.default || [];
936
+ if (children.length) points.push({ parent: selected.id, slot: 'default', index: children.length, after: children.at(-1), end: true });
937
+ return points.filter((point) => this.dropFilter({ parent_id: point.parent, slot: point.slot, index: point.index }, null));
938
+ }
939
+
940
+ /** The frame's viewport on the host page and the part of it that is visible (clip). */
941
+ frameMetrics() {
942
+ const iframe = this.iframe;
943
+ const win = this.frameDoc?.defaultView;
944
+ if (!iframe?.isConnected || !win) return null;
945
+ const rect = iframe.getBoundingClientRect();
946
+ if (!rect.width && !rect.height) return null;
947
+ const style = globalThis.getComputedStyle(iframe);
948
+ const sides = (name, suffix = '') => ({ top: px(style[`${name}Top${suffix}`]), right: px(style[`${name}Right${suffix}`]), bottom: px(style[`${name}Bottom${suffix}`]), left: px(style[`${name}Left${suffix}`]) });
949
+ const view = frameViewport({ rect, layout: { width: iframe.offsetWidth, height: iframe.offsetHeight }, border: sides('border', 'Width'), padding: sides('padding'), inner: { width: win.innerWidth, height: win.innerHeight }, scroll: { x: win.scrollX, y: win.scrollY } });
950
+ let clip = { left: view.left, top: view.top, right: view.left + view.width, bottom: view.top + view.height };
951
+ // Scrolling ancestors hide what lies outside them, and so does the window.
952
+ const root = globalThis.document.documentElement;
953
+ for (let element = iframe.parentElement; element && element !== root && element !== globalThis.document.body; element = element.parentElement) {
954
+ const own = globalThis.getComputedStyle(element);
955
+ if (own.overflowX === 'visible' && own.overflowY === 'visible') continue;
956
+ const box = element.getBoundingClientRect();
957
+ const left = box.left + element.clientLeft;
958
+ const top = box.top + element.clientTop;
959
+ clip = intersect(clip, { left, top, right: left + element.clientWidth, bottom: top + element.clientHeight });
960
+ }
961
+ view.clip = intersect(clip, { left: 0, top: 0, right: root.clientWidth, bottom: root.clientHeight });
962
+ return view;
963
+ }
964
+
965
+ applyClip(clip) {
966
+ const layer = this.layer.getBoundingClientRect();
967
+ const value = !clip || clip.right <= clip.left || clip.bottom <= clip.top
968
+ ? 'inset(50%)'
969
+ : `inset(${Math.max(0, clip.top - layer.top)}px ${Math.max(0, layer.right - clip.right)}px ${Math.max(0, layer.bottom - clip.bottom)}px ${Math.max(0, clip.left - layer.left)}px)`;
970
+ this.layer.style.clipPath = value;
971
+ this.insertionLayer.style.clipPath = value;
972
+ }
973
+
974
+ positionFrameToolbar(box) {
975
+ const clip = this.view?.clip;
976
+ const visible = Boolean(box && clip && box.right > clip.left && box.left < clip.right && box.bottom > clip.top && box.top < clip.bottom);
977
+ if (visible) this.toolbar.hidden = this.actions.length === 0;
978
+ else if (!this.toolbar.contains(globalThis.document.activeElement)) this.toolbar.hidden = true;
979
+ if (!visible || this.toolbar.hidden) return;
980
+ const bar = this.toolbar.getBoundingClientRect();
981
+ // Above the node, else below it, and always inside the visible part of the frame.
982
+ const above = box.top - bar.height - 4;
983
+ let top = above >= clip.top ? above : box.bottom + 4;
984
+ top = Math.max(clip.top, Math.min(top, clip.bottom - bar.height));
985
+ const rtl = globalThis.getComputedStyle(this).direction === 'rtl';
986
+ const start = rtl ? box.right - bar.width : box.left;
987
+ const left = Math.max(0, Math.min(start, globalThis.document.documentElement.clientWidth - bar.width));
988
+ Object.assign(this.toolbar.style, { top: `${top}px`, left: `${left}px` });
989
+ }
990
+
991
+ /** The innermost node under a pointer event in the frame: the smallest element or rect node that holds it. */
992
+ frameNodeAt(event) {
993
+ let best = null;
994
+ let area = Infinity;
995
+ const consider = (id, width, height) => {
996
+ if (width * height < area) {
997
+ best = id;
998
+ area = width * height;
999
+ }
1000
+ };
1001
+ for (let element = isElement(event.target) ? event.target : event.target?.parentElement; element; element = element.parentElement) {
1002
+ const id = this.elementIds.get(element);
1003
+ if (id === undefined || !this.byId.has(id)) continue;
1004
+ const box = element.getBoundingClientRect();
1005
+ consider(id, box.width, box.height);
1006
+ }
1007
+ const win = this.frameDoc?.defaultView;
1008
+ if (win && this.rectNodes.length && finite(event.clientX)) {
1009
+ const x = event.clientX + win.scrollX;
1010
+ const y = event.clientY + win.scrollY;
1011
+ for (const { id, flat: { rect } } of this.rectNodes) {
1012
+ if (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height) consider(id, rect.width, rect.height);
1013
+ }
1014
+ }
1015
+ return best;
1016
+ }
1017
+
1018
+ onFrameClick(event) {
1019
+ if (this.editing && this.editing.element.contains(event.target)) return;
1020
+ // In the editor a click selects; it never follows links or submits. A
1021
+ // click on no node goes back to no selection (the letter itself).
1022
+ event.preventDefault();
1023
+ const id = this.frameNodeAt(event);
1024
+ if (id) event.stopPropagation();
1025
+ this.select(id);
1026
+ }
1027
+
1028
+ onFrameDoubleClick(event) {
1029
+ if (this.editing) return;
1030
+ const id = this.frameNodeAt(event);
1031
+ if (!id || !this.byId.get(id).flat?.editable) return;
1032
+ event.preventDefault();
1033
+ this.edit(id);
1034
+ }
1035
+
1036
+ onFrameKey(event) {
1037
+ if (this.editing) {
1038
+ if (event.isComposing) return;
1039
+ if (event.key === 'Enter' || event.key === 'Escape') {
1040
+ event.preventDefault();
1041
+ event.stopPropagation();
1042
+ this.finishEdit(event.key === 'Enter');
1043
+ }
1044
+ return;
1045
+ }
1046
+ if (!event.defaultPrevented) this.onTreeKey(event);
1047
+ }
1048
+
1049
+ /** Starts plain-string editing of an editable node inside the frame; returns whether it started. */
1050
+ edit(id) {
1051
+ const node = typeof id === 'string' ? this.byId.get(id) : null;
1052
+ const element = node?.flat?.editable ? node.flat.element : null;
1053
+ if (!element || !this.frameDoc || !element.isConnected || element.ownerDocument !== this.frameDoc) return false;
1054
+ if (this.editing?.id === id) return true;
1055
+ if (this.editing) this.finishEdit(true);
1056
+ const returnFocus = globalThis.document.activeElement === this.tree;
1057
+ this.select(id);
1058
+ const doc = element.ownerDocument;
1059
+ const attribute = element.getAttribute('contenteditable');
1060
+ element.setAttribute('contenteditable', 'plaintext-only');
1061
+ // Where plaintext-only is unknown, rich editing is narrowed to plain text by hand.
1062
+ const plain = element.contentEditable === 'plaintext-only';
1063
+ if (!plain) element.setAttribute('contenteditable', 'true');
1064
+ const connection = new AbortController();
1065
+ const options = { signal: connection.signal };
1066
+ element.addEventListener('blur', () => this.finishEdit(true), options);
1067
+ element.addEventListener('input', () => this.schedule(), options);
1068
+ element.addEventListener('beforeinput', (event) => {
1069
+ const type = event.inputType || '';
1070
+ if (type === 'insertParagraph' || type === 'insertLineBreak' || (!plain && type.startsWith('format'))) event.preventDefault();
1071
+ }, options);
1072
+ if (!plain) {
1073
+ element.addEventListener('paste', (event) => {
1074
+ event.preventDefault();
1075
+ doc.execCommand('insertText', false, (event.clipboardData?.getData('text/plain') || '').replace(/\s+/gu, ' '));
1076
+ }, options);
1077
+ }
1078
+ this.editing = { id, element, saved: element.cloneNode(true), previous: plainText(element), attribute, connection, returnFocus };
1079
+ holdOpenSurfaceMark(this, true);
1080
+ this.setAttribute('data-sf-overlay-editing', '');
1081
+ element.focus({ preventScroll: true });
1082
+ const selection = doc.defaultView?.getSelection();
1083
+ if (selection) {
1084
+ const range = doc.createRange();
1085
+ range.selectNodeContents(element);
1086
+ selection.removeAllRanges();
1087
+ selection.addRange(range);
1088
+ }
1089
+ this.live.textContent = format(this.messages.editing, { label: this.info(node).label });
1090
+ this.schedule();
1091
+ return true;
1092
+ }
1093
+
1094
+ /** Ends the inline edit: commit emits sf-composition-overlay-edit (cancelable) when the text changed; cancel restores it. */
1095
+ finishEdit(commit) {
1096
+ const editing = this.editing;
1097
+ if (!editing) return;
1098
+ this.editing = null;
1099
+ editing.connection.abort();
1100
+ holdOpenSurfaceMark(this, false);
1101
+ this.removeAttribute('data-sf-overlay-editing');
1102
+ const { element } = editing;
1103
+ if (editing.attribute === null) element.removeAttribute('contenteditable');
1104
+ else element.setAttribute('contenteditable', editing.attribute);
1105
+ const restore = () => {
1106
+ if (element.isConnected) element.replaceChildren(...editing.saved.childNodes);
1107
+ };
1108
+ const value = plainText(element);
1109
+ if (!commit) restore();
1110
+ else if (value !== editing.previous) {
1111
+ const event = new globalThis.CustomEvent(OVERLAY_EVENTS.edit, {
1112
+ bubbles: true, composed: true, cancelable: true, detail: Object.freeze({ node_id: editing.id, value, previous: editing.previous }),
1113
+ });
1114
+ this.dispatchEvent(event);
1115
+ // The host refused the value: the letter shows what it showed before.
1116
+ if (event.defaultPrevented) restore();
1117
+ }
1118
+ if (editing.returnFocus && this.isConnected) this.tree.focus({ preventScroll: true });
1119
+ this.schedule();
1120
+ }
673
1121
  }
674
1122
 
675
1123
  registry.define('sf-composition-overlay', SfCompositionOverlay);
676
1124
  return SfCompositionOverlay;
677
1125
  }
678
1126
 
679
- export default { OVERLAY_EVENTS, checkOverlayActions, defineCompositionOverlay, overlayNodes };
1127
+ export default { OVERLAY_EVENTS, checkOverlayActions, checkOverlayNodes, defineCompositionOverlay, flatOverlayNodes, frameRectToHost, frameViewport, overlayNodes };