@elytracms/studio 0.0.63 → 0.0.65

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 (146) hide show
  1. package/convex/assets.ts +11 -0
  2. package/convex/delivery.ts +5 -0
  3. package/convex/schema.ts +23 -0
  4. package/dist/components/assets/asset-thumb.d.ts +4 -2
  5. package/dist/components/assets/asset-thumb.js +26 -4
  6. package/dist/components/assets/asset-thumb.js.map +1 -1
  7. package/dist/components/assets/assets-module.js +48 -2
  8. package/dist/components/assets/assets-module.js.map +1 -1
  9. package/dist/components/assets/assets-store.d.ts +11 -1
  10. package/dist/components/assets/assets-store.js +18 -1
  11. package/dist/components/assets/assets-store.js.map +1 -1
  12. package/dist/components/builder/blocks-model.d.ts +11 -0
  13. package/dist/components/builder/blocks-model.js +26 -8
  14. package/dist/components/builder/blocks-model.js.map +1 -1
  15. package/dist/components/builder/canvas-chrome-css.d.ts +54 -0
  16. package/dist/components/builder/canvas-chrome-css.js +246 -0
  17. package/dist/components/builder/canvas-chrome-css.js.map +1 -0
  18. package/dist/components/builder/canvas-chrome.d.ts +122 -18
  19. package/dist/components/builder/canvas-chrome.js +148 -82
  20. package/dist/components/builder/canvas-chrome.js.map +1 -1
  21. package/dist/components/builder/canvas-inline-editing.d.ts +7 -0
  22. package/dist/components/builder/canvas-inline-editing.js +3 -1
  23. package/dist/components/builder/canvas-inline-editing.js.map +1 -1
  24. package/dist/components/builder/canvas-overlay-geometry.d.ts +72 -0
  25. package/dist/components/builder/canvas-overlay-geometry.js +80 -0
  26. package/dist/components/builder/canvas-overlay-geometry.js.map +1 -0
  27. package/dist/components/builder/canvas-rect-tracking.d.ts +124 -0
  28. package/dist/components/builder/canvas-rect-tracking.js +198 -0
  29. package/dist/components/builder/canvas-rect-tracking.js.map +1 -0
  30. package/dist/components/builder/canvas-render-context.d.ts +13 -0
  31. package/dist/components/builder/canvas-render-context.js +7 -5
  32. package/dist/components/builder/canvas-render-context.js.map +1 -1
  33. package/dist/components/builder/canvas-stylesheet.d.ts +1 -1
  34. package/dist/components/builder/canvas-stylesheet.js +16 -9
  35. package/dist/components/builder/canvas-stylesheet.js.map +1 -1
  36. package/dist/components/builder/canvas-theme-notice.d.ts +6 -0
  37. package/dist/components/builder/canvas-theme-notice.js +46 -0
  38. package/dist/components/builder/canvas-theme-notice.js.map +1 -0
  39. package/dist/components/builder/canvas.js +78 -22
  40. package/dist/components/builder/canvas.js.map +1 -1
  41. package/dist/components/builder/composition-field-editor.js +22 -3
  42. package/dist/components/builder/composition-field-editor.js.map +1 -1
  43. package/dist/components/builder/delete-key.d.ts +38 -0
  44. package/dist/components/builder/delete-key.js +46 -0
  45. package/dist/components/builder/delete-key.js.map +1 -0
  46. package/dist/components/builder/drag-session.d.ts +8 -0
  47. package/dist/components/builder/drag-session.js +15 -0
  48. package/dist/components/builder/drag-session.js.map +1 -1
  49. package/dist/components/builder/insert-affordance-context.d.ts +19 -0
  50. package/dist/components/builder/insert-affordance-context.js +7 -0
  51. package/dist/components/builder/insert-affordance-context.js.map +1 -0
  52. package/dist/components/builder/insert-context.d.ts +40 -0
  53. package/dist/components/builder/insert-context.js +86 -3
  54. package/dist/components/builder/insert-context.js.map +1 -1
  55. package/dist/components/builder/node-title.d.ts +60 -0
  56. package/dist/components/builder/node-title.js +135 -0
  57. package/dist/components/builder/node-title.js.map +1 -0
  58. package/dist/components/builder/page-tree.js +21 -3
  59. package/dist/components/builder/page-tree.js.map +1 -1
  60. package/dist/components/builder/prop-selection.d.ts +52 -0
  61. package/dist/components/builder/prop-selection.js +38 -0
  62. package/dist/components/builder/prop-selection.js.map +1 -0
  63. package/dist/components/builder/prop-sync-context.d.ts +49 -0
  64. package/dist/components/builder/prop-sync-context.js +23 -0
  65. package/dist/components/builder/prop-sync-context.js.map +1 -0
  66. package/dist/components/builder/property-inspector.js +23 -1
  67. package/dist/components/builder/property-inspector.js.map +1 -1
  68. package/dist/components/builder/reuse-context.js +1 -1
  69. package/dist/components/builder/reuse-context.js.map +1 -1
  70. package/dist/components/builder/rich-text-toolbar.d.ts +23 -0
  71. package/dist/components/builder/rich-text-toolbar.js +24 -17
  72. package/dist/components/builder/rich-text-toolbar.js.map +1 -1
  73. package/dist/components/builder/selection-overlay.d.ts +79 -0
  74. package/dist/components/builder/selection-overlay.js +244 -0
  75. package/dist/components/builder/selection-overlay.js.map +1 -0
  76. package/dist/components/builder/spacing-handles.d.ts +88 -1
  77. package/dist/components/builder/spacing-handles.js +191 -63
  78. package/dist/components/builder/spacing-handles.js.map +1 -1
  79. package/dist/components/cms/asset-picker.js +11 -5
  80. package/dist/components/cms/asset-picker.js.map +1 -1
  81. package/dist/components/cms/rich-text/block-style.d.ts +48 -0
  82. package/dist/components/cms/rich-text/block-style.js +30 -0
  83. package/dist/components/cms/rich-text/block-style.js.map +1 -0
  84. package/dist/components/cms/rich-text/embed-picker.js +4 -3
  85. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  86. package/dist/components/cms/rich-text/extensions.d.ts +2 -0
  87. package/dist/components/cms/rich-text/extensions.js +4 -0
  88. package/dist/components/cms/rich-text/extensions.js.map +1 -1
  89. package/dist/components/cms/rich-text/font-size-control.d.ts +31 -0
  90. package/dist/components/cms/rich-text/font-size-control.js +40 -0
  91. package/dist/components/cms/rich-text/font-size-control.js.map +1 -0
  92. package/dist/components/cms/rich-text/font-size.d.ts +16 -0
  93. package/dist/components/cms/rich-text/font-size.js +40 -0
  94. package/dist/components/cms/rich-text/font-size.js.map +1 -0
  95. package/dist/components/cms/rich-text/toolbar-state.d.ts +3 -0
  96. package/dist/components/cms/rich-text/toolbar-state.js +3 -0
  97. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
  98. package/dist/components/cms/rich-text/toolbar.d.ts +13 -0
  99. package/dist/components/cms/rich-text/toolbar.js +17 -5
  100. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  101. package/dist/components/components/component-detail.js +3 -3
  102. package/dist/components/components/component-detail.js.map +1 -1
  103. package/dist/components/components/component-preview.d.ts +2 -1
  104. package/dist/components/components/component-preview.js +25 -26
  105. package/dist/components/components/component-preview.js.map +1 -1
  106. package/dist/components/components/registry-browser.js +2 -2
  107. package/dist/components/components/registry-browser.js.map +1 -1
  108. package/dist/components/components/taxonomy.js +3 -2
  109. package/dist/components/components/taxonomy.js.map +1 -1
  110. package/dist/components/components/transfer-dialog.js +1 -1
  111. package/dist/components/components/transfer-dialog.js.map +1 -1
  112. package/dist/lib/assets/backfill-thumbs.d.ts +67 -0
  113. package/dist/lib/assets/backfill-thumbs.js +120 -0
  114. package/dist/lib/assets/backfill-thumbs.js.map +1 -0
  115. package/dist/lib/assets/delete-asset.d.ts +2 -1
  116. package/dist/lib/assets/delete-asset.js +12 -0
  117. package/dist/lib/assets/delete-asset.js.map +1 -1
  118. package/dist/lib/assets/index.d.ts +2 -0
  119. package/dist/lib/assets/index.js +2 -0
  120. package/dist/lib/assets/index.js.map +1 -1
  121. package/dist/lib/assets/thumbnail.d.ts +145 -0
  122. package/dist/lib/assets/thumbnail.js +220 -0
  123. package/dist/lib/assets/thumbnail.js.map +1 -0
  124. package/dist/lib/assets/upload.d.ts +9 -0
  125. package/dist/lib/assets/upload.js +31 -0
  126. package/dist/lib/assets/upload.js.map +1 -1
  127. package/dist/lib/i18n/messages.de.js +20 -0
  128. package/dist/lib/i18n/messages.de.js.map +1 -1
  129. package/dist/lib/i18n/messages.en.d.ts +18 -0
  130. package/dist/lib/i18n/messages.en.js +21 -0
  131. package/dist/lib/i18n/messages.en.js.map +1 -1
  132. package/dist/lib/persistence/backend-mode.d.ts +7 -0
  133. package/dist/lib/persistence/backend-mode.js +9 -0
  134. package/dist/lib/persistence/backend-mode.js.map +1 -1
  135. package/dist/lib/persistence/convex-adapter.js +4 -0
  136. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  137. package/dist/lib/persistence/index.d.ts +1 -1
  138. package/dist/lib/persistence/index.js +1 -1
  139. package/dist/lib/persistence/index.js.map +1 -1
  140. package/dist/lib/persona/labels.d.ts +9 -5
  141. package/dist/lib/persona/labels.js +13 -8
  142. package/dist/lib/persona/labels.js.map +1 -1
  143. package/dist/lib/references/references-store.js +7 -1
  144. package/dist/lib/references/references-store.js.map +1 -1
  145. package/package.json +2 -2
  146. package/src/styles/studio.css +13 -0
@@ -0,0 +1,46 @@
1
+ import { clipboardEventBlocked } from './clipboard-actions.js';
2
+ /**
3
+ * Delete/Backspace removes the selected node (EC-404, scope 3) — the gap the
4
+ * findings batch surfaced: the studio had no keyboard remove at all except cut
5
+ * (⌘X). This sits beside the clipboard listeners (`attachClipboardListeners`),
6
+ * wired on the same two surfaces (the studio window for the page tree, the
7
+ * iframe document for the canvas), and reuses the SAME editing-context guard so
8
+ * pressing Backspace while typing in an input or an inline text/rich-text editor
9
+ * edits the text — never deletes the block.
10
+ */
11
+ /**
12
+ * True when a key event's target owns its own text-editing behavior — a form
13
+ * field or any contenteditable (which includes the EC-158/EC-159 inline
14
+ * editors). The single guard for the Delete/Backspace remove, kept identical to
15
+ * the clipboard shortcuts' scope so the two can never disagree.
16
+ */
17
+ export function isEditingContext(target) {
18
+ return clipboardEventBlocked(target);
19
+ }
20
+ /**
21
+ * Wire Delete/Backspace → remove the selected node. Inert when the event
22
+ * targets an editing context, when nothing (or the root) is selected, or when
23
+ * disabled. Modifier combinations are ignored so it never shadows a browser
24
+ * shortcut (⌘Delete etc.).
25
+ */
26
+ export function attachDeleteKeyListener(target, opts) {
27
+ const onKeyDown = (e) => {
28
+ const event = e;
29
+ if (event.key !== 'Delete' && event.key !== 'Backspace')
30
+ return;
31
+ if (event.metaKey || event.ctrlKey || event.altKey)
32
+ return;
33
+ if (opts.isDisabled?.() ?? false)
34
+ return;
35
+ if (isEditingContext(event.target))
36
+ return;
37
+ const selectedId = opts.getSelectedId();
38
+ if (!selectedId || selectedId === opts.getRootId())
39
+ return;
40
+ event.preventDefault();
41
+ opts.remove(selectedId);
42
+ };
43
+ target.addEventListener('keydown', onKeyDown);
44
+ return () => target.removeEventListener('keydown', onKeyDown);
45
+ }
46
+ //# sourceMappingURL=delete-key.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delete-key.js","sourceRoot":"","sources":["../../../src/components/builder/delete-key.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAA;AAE3D;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAA0B;IACzD,OAAO,qBAAqB,CAAC,MAAM,CAAC,CAAA;AACtC,CAAC;AAkBD;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB,CACrC,MAAsB,EACtB,IAA8B;IAE9B,MAAM,SAAS,GAAG,CAAC,CAAQ,EAAQ,EAAE;QACnC,MAAM,KAAK,GAAG,CAAkB,CAAA;QAChC,IAAI,KAAK,CAAC,GAAG,KAAK,QAAQ,IAAI,KAAK,CAAC,GAAG,KAAK,WAAW;YAAE,OAAM;QAC/D,IAAI,KAAK,CAAC,OAAO,IAAI,KAAK,CAAC,OAAO,IAAI,KAAK,CAAC,MAAM;YAAE,OAAM;QAC1D,IAAI,IAAI,CAAC,UAAU,EAAE,EAAE,IAAI,KAAK;YAAE,OAAM;QACxC,IAAI,gBAAgB,CAAC,KAAK,CAAC,MAAM,CAAC;YAAE,OAAM;QAC1C,MAAM,UAAU,GAAG,IAAI,CAAC,aAAa,EAAE,CAAA;QACvC,IAAI,CAAC,UAAU,IAAI,UAAU,KAAK,IAAI,CAAC,SAAS,EAAE;YAAE,OAAM;QAC1D,KAAK,CAAC,cAAc,EAAE,CAAA;QACtB,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAA;IACzB,CAAC,CAAA;IACD,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;IAC7C,OAAO,GAAG,EAAE,CAAC,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;AAC/D,CAAC","sourcesContent":["import { clipboardEventBlocked } from './clipboard-actions'\n\n/**\n * Delete/Backspace removes the selected node (EC-404, scope 3) — the gap the\n * findings batch surfaced: the studio had no keyboard remove at all except cut\n * (⌘X). This sits beside the clipboard listeners (`attachClipboardListeners`),\n * wired on the same two surfaces (the studio window for the page tree, the\n * iframe document for the canvas), and reuses the SAME editing-context guard so\n * pressing Backspace while typing in an input or an inline text/rich-text editor\n * edits the text — never deletes the block.\n */\n\n/**\n * True when a key event's target owns its own text-editing behavior — a form\n * field or any contenteditable (which includes the EC-158/EC-159 inline\n * editors). The single guard for the Delete/Backspace remove, kept identical to\n * the clipboard shortcuts' scope so the two can never disagree.\n */\nexport function isEditingContext(target: EventTarget | null): boolean {\n return clipboardEventBlocked(target)\n}\n\nexport interface DeleteKeyListenerOptions {\n /** The current selection — the remove subject. */\n getSelectedId: () => string | null\n /** Dispatch the remove (the existing `remove` tree action; one undo restores). */\n remove: (id: string) => void\n /** The id that must never be removed (the composition root). */\n getRootId: () => string | null\n /** Extra suppression (e.g. a read-only published preview). */\n isDisabled?: () => boolean\n}\n\ninterface ListenerTarget {\n addEventListener(type: string, listener: (e: Event) => void): void\n removeEventListener(type: string, listener: (e: Event) => void): void\n}\n\n/**\n * Wire Delete/Backspace → remove the selected node. Inert when the event\n * targets an editing context, when nothing (or the root) is selected, or when\n * disabled. Modifier combinations are ignored so it never shadows a browser\n * shortcut (⌘Delete etc.).\n */\nexport function attachDeleteKeyListener(\n target: ListenerTarget,\n opts: DeleteKeyListenerOptions,\n): () => void {\n const onKeyDown = (e: Event): void => {\n const event = e as KeyboardEvent\n if (event.key !== 'Delete' && event.key !== 'Backspace') return\n if (event.metaKey || event.ctrlKey || event.altKey) return\n if (opts.isDisabled?.() ?? false) return\n if (isEditingContext(event.target)) return\n const selectedId = opts.getSelectedId()\n if (!selectedId || selectedId === opts.getRootId()) return\n event.preventDefault()\n opts.remove(selectedId)\n }\n target.addEventListener('keydown', onKeyDown)\n return () => target.removeEventListener('keydown', onKeyDown)\n}\n"]}
@@ -26,6 +26,7 @@ export interface DragSession {
26
26
  buildNode?: () => ComponentNode;
27
27
  }
28
28
  type EndListener = (outcome: DragOutcome, session: DragSession) => void;
29
+ type StartListener = (session: DragSession) => void;
29
30
  /** Begin a drag session and stamp the dataTransfer (Firefox requires data to drag). */
30
31
  export declare function beginDrag(session: DragSession, dataTransfer: DataTransfer | null): void;
31
32
  /** The live session, or null when nothing Elytra-owned is being dragged. */
@@ -38,4 +39,11 @@ export declare function activeDrag(): DragSession | null;
38
39
  export declare function endDrag(outcome: DragOutcome): void;
39
40
  /** Subscribe to session end (indicator cleanup, blocks-panel close-on-drop). */
40
41
  export declare function onDragSessionEnd(listener: EndListener): () => void;
42
+ /**
43
+ * Subscribe to session start — fired for EVERY drag source (canvas node, tree
44
+ * row, blocks-panel tile). The canvas insertion affordances (EC-405) use this to
45
+ * hide while a drag is live: the DnD before/after/into indicators own the "where
46
+ * it lands" story during a drag, so a competing "+" would be noise.
47
+ */
48
+ export declare function onDragSessionStart(listener: StartListener): () => void;
41
49
  export {};
@@ -15,6 +15,7 @@
15
15
  export const ELYTRA_DND_MIME = 'application/x-elytra-canvas';
16
16
  let active = null;
17
17
  const endListeners = new Set();
18
+ const startListeners = new Set();
18
19
  /** Begin a drag session and stamp the dataTransfer (Firefox requires data to drag). */
19
20
  export function beginDrag(session, dataTransfer) {
20
21
  active = session;
@@ -23,6 +24,8 @@ export function beginDrag(session, dataTransfer) {
23
24
  dataTransfer.setData('text/plain', session.label);
24
25
  dataTransfer.effectAllowed = session.subject.kind === 'node' ? 'move' : 'copy';
25
26
  }
27
+ for (const listener of [...startListeners])
28
+ listener(session);
26
29
  }
27
30
  /** The live session, or null when nothing Elytra-owned is being dragged. */
28
31
  export function activeDrag() {
@@ -48,4 +51,16 @@ export function onDragSessionEnd(listener) {
48
51
  endListeners.delete(listener);
49
52
  };
50
53
  }
54
+ /**
55
+ * Subscribe to session start — fired for EVERY drag source (canvas node, tree
56
+ * row, blocks-panel tile). The canvas insertion affordances (EC-405) use this to
57
+ * hide while a drag is live: the DnD before/after/into indicators own the "where
58
+ * it lands" story during a drag, so a competing "+" would be noise.
59
+ */
60
+ export function onDragSessionStart(listener) {
61
+ startListeners.add(listener);
62
+ return () => {
63
+ startListeners.delete(listener);
64
+ };
65
+ }
51
66
  //# sourceMappingURL=drag-session.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"drag-session.js","sourceRoot":"","sources":["../../../src/components/builder/drag-session.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;GAYG;AAEH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,eAAe,GAAG,6BAA6B,CAAA;AAgB5D,IAAI,MAAM,GAAuB,IAAI,CAAA;AACrC,MAAM,YAAY,GAAG,IAAI,GAAG,EAAe,CAAA;AAE3C,uFAAuF;AACvF,MAAM,UAAU,SAAS,CAAC,OAAoB,EAAE,YAAiC;IAC/E,MAAM,GAAG,OAAO,CAAA;IAChB,IAAI,YAAY,EAAE,CAAC;QACjB,YAAY,CAAC,OAAO,CAAC,eAAe,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;QACpD,YAAY,CAAC,OAAO,CAAC,YAAY,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;QACjD,YAAY,CAAC,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAA;IAChF,CAAC;AACH,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,UAAU;IACxB,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,OAAO,CAAC,OAAoB;IAC1C,IAAI,CAAC,MAAM;QAAE,OAAM;IACnB,MAAM,OAAO,GAAG,MAAM,CAAA;IACtB,MAAM,GAAG,IAAI,CAAA;IACb,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,YAAY,CAAC;QAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;AACtE,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,gBAAgB,CAAC,QAAqB;IACpD,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAC1B,OAAO,GAAG,EAAE;QACV,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC/B,CAAC,CAAA;AACH,CAAC","sourcesContent":["import type { ComponentNode } from '@elytracms/core/project-graph'\nimport type { DragSubject } from '@elytracms/studio/lib/dnd/drop-rules'\n\n/**\n * The one live drag session (EC-160). HTML5 `dataTransfer` payloads are\n * unreadable during `dragover`, so the canvas, page tree, and blocks panel\n * share this module-level session instead: the source begins it, every\n * surface reads it to resolve drop eligibility (via the pure rules in\n * `lib/dnd/drop-rules`), and exactly one place ends it — the drop handler\n * (`dropped`) or the source's `dragend` (`cancelled`). `endDrag` is\n * idempotent, so the source's trailing `dragend` after a successful drop\n * cannot double-commit or double-notify.\n *\n * The iframe canvas shares the parent window's JS realm (listeners are wired\n * from the parent), so one module instance covers all surfaces.\n */\n\n/** Custom MIME marking Elytra drags (lets surfaces ignore foreign drags). */\nexport const ELYTRA_DND_MIME = 'application/x-elytra-canvas'\n\nexport type DragSource = 'canvas' | 'tree' | 'blocks'\nexport type DragOutcome = 'dropped' | 'cancelled'\n\nexport interface DragSession {\n subject: DragSubject\n source: DragSource\n /** Human label (drag text fallback). */\n label: string\n /** For `component` subjects: builds the subtree to insert (ids reassigned by the store). */\n buildNode?: () => ComponentNode\n}\n\ntype EndListener = (outcome: DragOutcome, session: DragSession) => void\n\nlet active: DragSession | null = null\nconst endListeners = new Set<EndListener>()\n\n/** Begin a drag session and stamp the dataTransfer (Firefox requires data to drag). */\nexport function beginDrag(session: DragSession, dataTransfer: DataTransfer | null): void {\n active = session\n if (dataTransfer) {\n dataTransfer.setData(ELYTRA_DND_MIME, session.label)\n dataTransfer.setData('text/plain', session.label)\n dataTransfer.effectAllowed = session.subject.kind === 'node' ? 'move' : 'copy'\n }\n}\n\n/** The live session, or null when nothing Elytra-owned is being dragged. */\nexport function activeDrag(): DragSession | null {\n return active\n}\n\n/**\n * End the live session (no-op when none is active) and notify subscribers.\n * Call with `dropped` from the drop handler that dispatched the action, and\n * with `cancelled` from the source's `dragend`.\n */\nexport function endDrag(outcome: DragOutcome): void {\n if (!active) return\n const session = active\n active = null\n for (const listener of [...endListeners]) listener(outcome, session)\n}\n\n/** Subscribe to session end (indicator cleanup, blocks-panel close-on-drop). */\nexport function onDragSessionEnd(listener: EndListener): () => void {\n endListeners.add(listener)\n return () => {\n endListeners.delete(listener)\n }\n}\n"]}
1
+ {"version":3,"file":"drag-session.js","sourceRoot":"","sources":["../../../src/components/builder/drag-session.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;GAYG;AAEH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,eAAe,GAAG,6BAA6B,CAAA;AAiB5D,IAAI,MAAM,GAAuB,IAAI,CAAA;AACrC,MAAM,YAAY,GAAG,IAAI,GAAG,EAAe,CAAA;AAC3C,MAAM,cAAc,GAAG,IAAI,GAAG,EAAiB,CAAA;AAE/C,uFAAuF;AACvF,MAAM,UAAU,SAAS,CAAC,OAAoB,EAAE,YAAiC;IAC/E,MAAM,GAAG,OAAO,CAAA;IAChB,IAAI,YAAY,EAAE,CAAC;QACjB,YAAY,CAAC,OAAO,CAAC,eAAe,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;QACpD,YAAY,CAAC,OAAO,CAAC,YAAY,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;QACjD,YAAY,CAAC,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAA;IAChF,CAAC;IACD,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,cAAc,CAAC;QAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;AAC/D,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,UAAU;IACxB,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,OAAO,CAAC,OAAoB;IAC1C,IAAI,CAAC,MAAM;QAAE,OAAM;IACnB,MAAM,OAAO,GAAG,MAAM,CAAA;IACtB,MAAM,GAAG,IAAI,CAAA;IACb,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,YAAY,CAAC;QAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;AACtE,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,gBAAgB,CAAC,QAAqB;IACpD,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAC1B,OAAO,GAAG,EAAE;QACV,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC/B,CAAC,CAAA;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,QAAuB;IACxD,cAAc,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAC5B,OAAO,GAAG,EAAE;QACV,cAAc,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IACjC,CAAC,CAAA;AACH,CAAC","sourcesContent":["import type { ComponentNode } from '@elytracms/core/project-graph'\nimport type { DragSubject } from '@elytracms/studio/lib/dnd/drop-rules'\n\n/**\n * The one live drag session (EC-160). HTML5 `dataTransfer` payloads are\n * unreadable during `dragover`, so the canvas, page tree, and blocks panel\n * share this module-level session instead: the source begins it, every\n * surface reads it to resolve drop eligibility (via the pure rules in\n * `lib/dnd/drop-rules`), and exactly one place ends it — the drop handler\n * (`dropped`) or the source's `dragend` (`cancelled`). `endDrag` is\n * idempotent, so the source's trailing `dragend` after a successful drop\n * cannot double-commit or double-notify.\n *\n * The iframe canvas shares the parent window's JS realm (listeners are wired\n * from the parent), so one module instance covers all surfaces.\n */\n\n/** Custom MIME marking Elytra drags (lets surfaces ignore foreign drags). */\nexport const ELYTRA_DND_MIME = 'application/x-elytra-canvas'\n\nexport type DragSource = 'canvas' | 'tree' | 'blocks'\nexport type DragOutcome = 'dropped' | 'cancelled'\n\nexport interface DragSession {\n subject: DragSubject\n source: DragSource\n /** Human label (drag text fallback). */\n label: string\n /** For `component` subjects: builds the subtree to insert (ids reassigned by the store). */\n buildNode?: () => ComponentNode\n}\n\ntype EndListener = (outcome: DragOutcome, session: DragSession) => void\ntype StartListener = (session: DragSession) => void\n\nlet active: DragSession | null = null\nconst endListeners = new Set<EndListener>()\nconst startListeners = new Set<StartListener>()\n\n/** Begin a drag session and stamp the dataTransfer (Firefox requires data to drag). */\nexport function beginDrag(session: DragSession, dataTransfer: DataTransfer | null): void {\n active = session\n if (dataTransfer) {\n dataTransfer.setData(ELYTRA_DND_MIME, session.label)\n dataTransfer.setData('text/plain', session.label)\n dataTransfer.effectAllowed = session.subject.kind === 'node' ? 'move' : 'copy'\n }\n for (const listener of [...startListeners]) listener(session)\n}\n\n/** The live session, or null when nothing Elytra-owned is being dragged. */\nexport function activeDrag(): DragSession | null {\n return active\n}\n\n/**\n * End the live session (no-op when none is active) and notify subscribers.\n * Call with `dropped` from the drop handler that dispatched the action, and\n * with `cancelled` from the source's `dragend`.\n */\nexport function endDrag(outcome: DragOutcome): void {\n if (!active) return\n const session = active\n active = null\n for (const listener of [...endListeners]) listener(outcome, session)\n}\n\n/** Subscribe to session end (indicator cleanup, blocks-panel close-on-drop). */\nexport function onDragSessionEnd(listener: EndListener): () => void {\n endListeners.add(listener)\n return () => {\n endListeners.delete(listener)\n }\n}\n\n/**\n * Subscribe to session start — fired for EVERY drag source (canvas node, tree\n * row, blocks-panel tile). The canvas insertion affordances (EC-405) use this to\n * hide while a drag is live: the DnD before/after/into indicators own the \"where\n * it lands\" story during a drag, so a competing \"+\" would be noise.\n */\nexport function onDragSessionStart(listener: StartListener): () => void {\n startListeners.add(listener)\n return () => {\n startListeners.delete(listener)\n }\n}\n"]}
@@ -0,0 +1,19 @@
1
+ import type { InsertPosition } from './insert-context.js';
2
+ /**
3
+ * Bridges the in-canvas insertion affordances (EC-405) to the insert panel state
4
+ * that lives on the composition-field editor. The `SelectionOverlay` (portaled
5
+ * inside the canvas iframe) asks this context which positions are insertable for a
6
+ * node and opens the panel at a chosen one; the editor owns the panel's
7
+ * `open`/`context` state and the field-level `allow` leash. A separate module (not
8
+ * the editor) so the deep overlay can consume it without a canvas ↔ editor import
9
+ * cycle, and so any canvas mount WITHOUT an editor above it (component preview)
10
+ * simply reads `null` and shows no affordances — the `ReuseProvider` pattern.
11
+ */
12
+ export interface InsertAffordanceApi {
13
+ /** The affordance positions (before/after/into) that may actually be inserted at `nodeId`. */
14
+ positionsFor(nodeId: string): InsertPosition[];
15
+ /** Select `nodeId` and open the blocks panel targeted at that exact position. */
16
+ openAt(nodeId: string, position: InsertPosition): void;
17
+ }
18
+ export declare const InsertAffordanceProvider: import("react").Provider<InsertAffordanceApi | null>;
19
+ export declare function useInsertAffordance(): InsertAffordanceApi | null;
@@ -0,0 +1,7 @@
1
+ import { createContext, useContext } from 'react';
2
+ const InsertAffordanceContext = createContext(null);
3
+ export const InsertAffordanceProvider = InsertAffordanceContext.Provider;
4
+ export function useInsertAffordance() {
5
+ return useContext(InsertAffordanceContext);
6
+ }
7
+ //# sourceMappingURL=insert-affordance-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"insert-affordance-context.js","sourceRoot":"","sources":["../../../src/components/builder/insert-affordance-context.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,OAAO,CAAA;AAoBjD,MAAM,uBAAuB,GAAG,aAAa,CAA6B,IAAI,CAAC,CAAA;AAE/E,MAAM,CAAC,MAAM,wBAAwB,GAAG,uBAAuB,CAAC,QAAQ,CAAA;AAExE,MAAM,UAAU,mBAAmB;IACjC,OAAO,UAAU,CAAC,uBAAuB,CAAC,CAAA;AAC5C,CAAC","sourcesContent":["import { createContext, useContext } from 'react'\nimport type { InsertPosition } from './insert-context'\n\n/**\n * Bridges the in-canvas insertion affordances (EC-405) to the insert panel state\n * that lives on the composition-field editor. The `SelectionOverlay` (portaled\n * inside the canvas iframe) asks this context which positions are insertable for a\n * node and opens the panel at a chosen one; the editor owns the panel's\n * `open`/`context` state and the field-level `allow` leash. A separate module (not\n * the editor) so the deep overlay can consume it without a canvas ↔ editor import\n * cycle, and so any canvas mount WITHOUT an editor above it (component preview)\n * simply reads `null` and shows no affordances — the `ReuseProvider` pattern.\n */\nexport interface InsertAffordanceApi {\n /** The affordance positions (before/after/into) that may actually be inserted at `nodeId`. */\n positionsFor(nodeId: string): InsertPosition[]\n /** Select `nodeId` and open the blocks panel targeted at that exact position. */\n openAt(nodeId: string, position: InsertPosition): void\n}\n\nconst InsertAffordanceContext = createContext<InsertAffordanceApi | null>(null)\n\nexport const InsertAffordanceProvider = InsertAffordanceContext.Provider\n\nexport function useInsertAffordance(): InsertAffordanceApi | null {\n return useContext(InsertAffordanceContext)\n}\n"]}
@@ -23,3 +23,43 @@ import type { InsertContext } from './insert-menu.js';
23
23
  export declare function resolveInsertContext(builder: {
24
24
  present: ComponentNode;
25
25
  }, nodeId: string, fieldAllow?: readonly string[], studio?: StudioRegistry): InsertContext | null;
26
+ /**
27
+ * An in-canvas insertion position relative to a reference node (EC-405) — the
28
+ * DnD position vocabulary (`lib/dnd/drop-rules`), narrowed to the three the
29
+ * insertion affordances offer: `before`/`after` a sibling, or `into` an (empty)
30
+ * container. `left`/`right` (the wrap-into-columns gesture) are DnD-only.
31
+ */
32
+ export type InsertPosition = 'before' | 'after' | 'into';
33
+ /**
34
+ * Resolve an `InsertContext` for an EXPLICIT position relative to `nodeId`
35
+ * (EC-405) — the position-carrying twin of {@link resolveInsertContext}, so an
36
+ * affordance click at a chosen edge lands the insert at exactly that spot rather
37
+ * than the panel re-deriving it from the selection.
38
+ *
39
+ * The slot resolution reuses the shared DnD rules — `into` uses
40
+ * {@link primaryInsertSlot} (the same slot a drop `into` picks; `null` when the
41
+ * node accepts no children), `before`/`after` use the reference's parent slot at
42
+ * `index`/`index + 1` — so this surface and drag-insert can never disagree about
43
+ * where a position lands or what it allows. `fieldAllow` (EC-188) still constrains
44
+ * ONLY a direct child of the composition root, mirroring `resolveInsertContext`.
45
+ */
46
+ export declare function resolveInsertContextAt(builder: {
47
+ present: ComponentNode;
48
+ }, nodeId: string, position: InsertPosition, fieldAllow?: readonly string[], studio?: StudioRegistry): InsertContext | null;
49
+ /**
50
+ * Which affordance positions are actually insertable at `nodeId` (EC-405 scope 3):
51
+ * a position is offered only when it structurally exists AND at least one registry
52
+ * component may land there (the SAME slot-rule filter the tree/DnD and the blocks
53
+ * panel apply — {@link hasInsertableComponent} over the resolved `slotAllow`), so a
54
+ * blocked spot shows no dead button.
55
+ *
56
+ * An EMPTY, insertable container surfaces ONLY `into` — the meaningful action there
57
+ * is "add the first child", and an empty container often renders zero-height, so
58
+ * its own before/after edges are degenerate and would pile the three "+" buttons
59
+ * onto one point. A filled container and every leaf get before/after instead (each
60
+ * child already carries its own edge affordances, so a filled container needs no
61
+ * center "+").
62
+ */
63
+ export declare function insertablePositions(builder: {
64
+ present: ComponentNode;
65
+ }, nodeId: string, fieldAllow?: readonly string[], studio?: StudioRegistry): InsertPosition[];
@@ -1,7 +1,8 @@
1
1
  import { getNode, getParentInfo } from '@elytracms/core/project-graph';
2
2
  import { getStudioRegistry } from '@elytracms/studio/lib/registry';
3
3
  import { shortId } from '@elytracms/studio/lib/persona/labels';
4
- import { intersectAllow } from './blocks-model.js';
4
+ import { primaryInsertSlot } from '@elytracms/studio/lib/dnd/drop-rules';
5
+ import { hasInsertableComponent, intersectAllow } from './blocks-model.js';
5
6
  /**
6
7
  * Where an insert lands: the canvas insert-context resolution, shared by every
7
8
  * surface that mounts the canvas engine.
@@ -27,8 +28,7 @@ export function resolveInsertContext(builder, nodeId, fieldAllow, studio = getSt
27
28
  if (!node)
28
29
  return null;
29
30
  const manifest = studio.registry.getManifest(node.componentId);
30
- const childSlot = manifest?.slots.find((s) => s.name === 'children') ??
31
- manifest?.slots.find((s) => s.name !== 'outlet');
31
+ const childSlot = manifest?.slots.find((s) => s.name === 'children') ?? manifest?.slots.find((s) => s.name !== 'outlet');
32
32
  const ctx = childSlot
33
33
  ? {
34
34
  target: { parentId: nodeId, slot: childSlot.name },
@@ -58,4 +58,87 @@ function resolveSiblingContext(studio, root, nodeId) {
58
58
  }
59
59
  return { target: { parentId: nodeId, slot: 'children' }, destinationLabel: 'root' };
60
60
  }
61
+ /**
62
+ * Resolve an `InsertContext` for an EXPLICIT position relative to `nodeId`
63
+ * (EC-405) — the position-carrying twin of {@link resolveInsertContext}, so an
64
+ * affordance click at a chosen edge lands the insert at exactly that spot rather
65
+ * than the panel re-deriving it from the selection.
66
+ *
67
+ * The slot resolution reuses the shared DnD rules — `into` uses
68
+ * {@link primaryInsertSlot} (the same slot a drop `into` picks; `null` when the
69
+ * node accepts no children), `before`/`after` use the reference's parent slot at
70
+ * `index`/`index + 1` — so this surface and drag-insert can never disagree about
71
+ * where a position lands or what it allows. `fieldAllow` (EC-188) still constrains
72
+ * ONLY a direct child of the composition root, mirroring `resolveInsertContext`.
73
+ */
74
+ export function resolveInsertContextAt(builder, nodeId, position, fieldAllow, studio = getStudioRegistry()) {
75
+ const root = builder.present;
76
+ const node = getNode(root, nodeId);
77
+ if (!node)
78
+ return null;
79
+ let ctx;
80
+ if (position === 'into') {
81
+ const manifest = studio.registry.getManifest(node.componentId);
82
+ const slot = primaryInsertSlot(manifest);
83
+ if (!slot)
84
+ return null; // the node accepts no children — no `into` position
85
+ ctx = {
86
+ target: {
87
+ parentId: nodeId,
88
+ slot: slot.name,
89
+ index: (node.slots?.[slot.name] ?? []).length,
90
+ },
91
+ slotAllow: slot.allow,
92
+ destinationLabel: `${shortId(node.componentId)} · ${slot.name}`,
93
+ };
94
+ }
95
+ else {
96
+ const info = getParentInfo(root, nodeId);
97
+ if (!info)
98
+ return null; // the root has no siblings — no before/after position
99
+ const pManifest = studio.registry.getManifest(info.parent.componentId);
100
+ ctx = {
101
+ target: {
102
+ parentId: info.parent.id,
103
+ slot: info.slot,
104
+ index: info.index + (position === 'after' ? 1 : 0),
105
+ },
106
+ slotAllow: pManifest?.slots.find((s) => s.name === info.slot)?.allow,
107
+ destinationLabel: `${shortId(info.parent.componentId)} · ${info.slot}`,
108
+ };
109
+ }
110
+ if (fieldAllow && fieldAllow.length > 0 && ctx.target.parentId === root.id) {
111
+ return { ...ctx, slotAllow: intersectAllow(ctx.slotAllow, fieldAllow) };
112
+ }
113
+ return ctx;
114
+ }
115
+ /**
116
+ * Which affordance positions are actually insertable at `nodeId` (EC-405 scope 3):
117
+ * a position is offered only when it structurally exists AND at least one registry
118
+ * component may land there (the SAME slot-rule filter the tree/DnD and the blocks
119
+ * panel apply — {@link hasInsertableComponent} over the resolved `slotAllow`), so a
120
+ * blocked spot shows no dead button.
121
+ *
122
+ * An EMPTY, insertable container surfaces ONLY `into` — the meaningful action there
123
+ * is "add the first child", and an empty container often renders zero-height, so
124
+ * its own before/after edges are degenerate and would pile the three "+" buttons
125
+ * onto one point. A filled container and every leaf get before/after instead (each
126
+ * child already carries its own edge affordances, so a filled container needs no
127
+ * center "+").
128
+ */
129
+ export function insertablePositions(builder, nodeId, fieldAllow, studio = getStudioRegistry()) {
130
+ // Empty insertable container → into only.
131
+ const into = resolveInsertContextAt(builder, nodeId, 'into', fieldAllow, studio);
132
+ if (into && (into.target.index ?? 0) === 0 && hasInsertableComponent(studio, into.slotAllow)) {
133
+ return ['into'];
134
+ }
135
+ // Otherwise the sibling edges that accept something.
136
+ const out = [];
137
+ for (const position of ['before', 'after']) {
138
+ const ctx = resolveInsertContextAt(builder, nodeId, position, fieldAllow, studio);
139
+ if (ctx && hasInsertableComponent(studio, ctx.slotAllow))
140
+ out.push(position);
141
+ }
142
+ return out;
143
+ }
61
144
  //# sourceMappingURL=insert-context.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"insert-context.js","sourceRoot":"","sources":["../../../src/components/builder/insert-context.ts"],"names":[],"mappings":"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","sourcesContent":["import { getNode, getParentInfo } from '@elytracms/core/project-graph'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { getStudioRegistry } from '@elytracms/studio/lib/registry'\nimport type { StudioRegistry } from '@elytracms/studio/lib/registry'\nimport { shortId } from '@elytracms/studio/lib/persona/labels'\nimport { intersectAllow } from './blocks-model'\nimport type { InsertContext } from './insert-menu'\n\n/**\n * Where an insert lands: the canvas insert-context resolution, shared by every\n * surface that mounts the canvas engine.\n *\n * Extracted from the retired standalone `BuilderShell` (EC-210) so the in-CMS\n * Canvas editor (`composition-field-editor.tsx`, EC-188/AD-12) can own it without\n * importing the dead shell. No UI here — pure graph/registry resolution.\n */\n\n/**\n * Resolve where an insert lands: into the node's child slot, else as a sibling.\n *\n * `fieldAllow` (EC-188) is the composition field's TOP-LEVEL vocabulary\n * (`field.allow`). It constrains ONLY inserts that land as a direct child of the\n * active page root — the top level of a composition field value — where it is\n * intersected into `slotAllow` so forbidden components are never even offered,\n * mirroring the top-level leash in `validateCompositionValue`. Deeper inserts keep\n * following each component's own slot `allow`. Callers that omit `fieldAllow` get\n * an unconstrained top level.\n */\nexport function resolveInsertContext(\n builder: { present: ComponentNode },\n nodeId: string,\n fieldAllow?: readonly string[],\n studio: StudioRegistry = getStudioRegistry(),\n): InsertContext | null {\n const root = builder.present\n const node = getNode(root, nodeId)\n if (!node) return null\n const manifest = studio.registry.getManifest(node.componentId)\n const childSlot =\n manifest?.slots.find((s) => s.name === 'children') ??\n manifest?.slots.find((s) => s.name !== 'outlet')\n const ctx: InsertContext = childSlot\n ? {\n target: { parentId: nodeId, slot: childSlot.name },\n slotAllow: manifest!.slots.find((s) => s.name === childSlot.name)?.allow,\n destinationLabel: `${shortId(node.componentId)} · ${childSlot.name}`,\n }\n : resolveSiblingContext(studio, root, nodeId)\n\n // The field vocabulary governs only the top level (a direct child of the page\n // root). `length > 0` matches the validator, which treats an empty allow as\n // \"allow any\" (validate.ts).\n if (fieldAllow && fieldAllow.length > 0 && ctx.target.parentId === root.id) {\n return { ...ctx, slotAllow: intersectAllow(ctx.slotAllow, fieldAllow) }\n }\n return ctx\n}\n\n/** Sibling-or-root insert context for a node with no insertable child slot. */\nfunction resolveSiblingContext(\n studio: StudioRegistry,\n root: ComponentNode,\n nodeId: string,\n): InsertContext {\n const info = getParentInfo(root, nodeId)\n if (info) {\n const pManifest = studio.registry.getManifest(info.parent.componentId)\n const allow = pManifest?.slots.find((s) => s.name === info.slot)?.allow\n return {\n target: { parentId: info.parent.id, slot: info.slot, index: info.index + 1 },\n slotAllow: allow,\n destinationLabel: `${shortId(info.parent.componentId)} · ${info.slot}`,\n }\n }\n return { target: { parentId: nodeId, slot: 'children' }, destinationLabel: 'root' }\n}\n"]}
1
+ {"version":3,"file":"insert-context.js","sourceRoot":"","sources":["../../../src/components/builder/insert-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAEtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAA;AAElE,OAAO,EAAE,OAAO,EAAE,MAAM,sCAAsC,CAAA;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,sCAAsC,CAAA;AACxE,OAAO,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAGvE;;;;;;;GAOG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,UAAU,oBAAoB,CAClC,OAAmC,EACnC,MAAc,EACd,UAA8B,EAC9B,SAAyB,iBAAiB,EAAE;IAE5C,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAA;IAC5B,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IAClC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAA;IACtB,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAC9D,MAAM,SAAS,GACb,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,UAAU,CAAC,IAAI,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAA;IACxG,MAAM,GAAG,GAAkB,SAAS;QAClC,CAAC,CAAC;YACE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE;YAClD,SAAS,EAAE,QAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,SAAS,CAAC,IAAI,CAAC,EAAE,KAAK;YACxE,gBAAgB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,SAAS,CAAC,IAAI,EAAE;SACrE;QACH,CAAC,CAAC,qBAAqB,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,CAAA;IAE/C,8EAA8E;IAC9E,4EAA4E;IAC5E,6BAA6B;IAC7B,IAAI,UAAU,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,KAAK,IAAI,CAAC,EAAE,EAAE,CAAC;QAC3E,OAAO,EAAE,GAAG,GAAG,EAAE,SAAS,EAAE,cAAc,CAAC,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,EAAE,CAAA;IACzE,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,+EAA+E;AAC/E,SAAS,qBAAqB,CAAC,MAAsB,EAAE,IAAmB,EAAE,MAAc;IACxF,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IACxC,IAAI,IAAI,EAAE,CAAC;QACT,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;QACtE,MAAM,KAAK,GAAG,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,CAAC,EAAE,KAAK,CAAA;QACvE,OAAO;YACL,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,GAAG,CAAC,EAAE;YAC5E,SAAS,EAAE,KAAK;YAChB,gBAAgB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE;SACvE,CAAA;IACH,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,gBAAgB,EAAE,MAAM,EAAE,CAAA;AACrF,CAAC;AAUD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,sBAAsB,CACpC,OAAmC,EACnC,MAAc,EACd,QAAwB,EACxB,UAA8B,EAC9B,SAAyB,iBAAiB,EAAE;IAE5C,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAA;IAC5B,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IAClC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAA;IAEtB,IAAI,GAAkB,CAAA;IACtB,IAAI,QAAQ,KAAK,MAAM,EAAE,CAAC;QACxB,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;QAC9D,MAAM,IAAI,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAA;QACxC,IAAI,CAAC,IAAI;YAAE,OAAO,IAAI,CAAA,CAAC,oDAAoD;QAC3E,GAAG,GAAG;YACJ,MAAM,EAAE;gBACN,QAAQ,EAAE,MAAM;gBAChB,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,KAAK,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM;aAC9C;YACD,SAAS,EAAE,IAAI,CAAC,KAAK;YACrB,gBAAgB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE;SAChE,CAAA;IACH,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;QACxC,IAAI,CAAC,IAAI;YAAE,OAAO,IAAI,CAAA,CAAC,sDAAsD;QAC7E,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;QACtE,GAAG,GAAG;YACJ,MAAM,EAAE;gBACN,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE;gBACxB,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,KAAK,EAAE,IAAI,CAAC,KAAK,GAAG,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;aACnD;YACD,SAAS,EAAE,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,CAAC,EAAE,KAAK;YACpE,gBAAgB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE;SACvE,CAAA;IACH,CAAC;IAED,IAAI,UAAU,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,KAAK,IAAI,CAAC,EAAE,EAAE,CAAC;QAC3E,OAAO,EAAE,GAAG,GAAG,EAAE,SAAS,EAAE,cAAc,CAAC,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,EAAE,CAAA;IACzE,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,mBAAmB,CACjC,OAAmC,EACnC,MAAc,EACd,UAA8B,EAC9B,SAAyB,iBAAiB,EAAE;IAE5C,0CAA0C;IAC1C,MAAM,IAAI,GAAG,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,CAAC,CAAA;IAChF,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,sBAAsB,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAC7F,OAAO,CAAC,MAAM,CAAC,CAAA;IACjB,CAAC;IACD,qDAAqD;IACrD,MAAM,GAAG,GAAqB,EAAE,CAAA;IAChC,KAAK,MAAM,QAAQ,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAU,EAAE,CAAC;QACpD,MAAM,GAAG,GAAG,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,CAAC,CAAA;QACjF,IAAI,GAAG,IAAI,sBAAsB,CAAC,MAAM,EAAE,GAAG,CAAC,SAAS,CAAC;YAAE,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAC9E,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC","sourcesContent":["import { getNode, getParentInfo } from '@elytracms/core/project-graph'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { getStudioRegistry } from '@elytracms/studio/lib/registry'\nimport type { StudioRegistry } from '@elytracms/studio/lib/registry'\nimport { shortId } from '@elytracms/studio/lib/persona/labels'\nimport { primaryInsertSlot } from '@elytracms/studio/lib/dnd/drop-rules'\nimport { hasInsertableComponent, intersectAllow } from './blocks-model'\nimport type { InsertContext } from './insert-menu'\n\n/**\n * Where an insert lands: the canvas insert-context resolution, shared by every\n * surface that mounts the canvas engine.\n *\n * Extracted from the retired standalone `BuilderShell` (EC-210) so the in-CMS\n * Canvas editor (`composition-field-editor.tsx`, EC-188/AD-12) can own it without\n * importing the dead shell. No UI here — pure graph/registry resolution.\n */\n\n/**\n * Resolve where an insert lands: into the node's child slot, else as a sibling.\n *\n * `fieldAllow` (EC-188) is the composition field's TOP-LEVEL vocabulary\n * (`field.allow`). It constrains ONLY inserts that land as a direct child of the\n * active page root — the top level of a composition field value — where it is\n * intersected into `slotAllow` so forbidden components are never even offered,\n * mirroring the top-level leash in `validateCompositionValue`. Deeper inserts keep\n * following each component's own slot `allow`. Callers that omit `fieldAllow` get\n * an unconstrained top level.\n */\nexport function resolveInsertContext(\n builder: { present: ComponentNode },\n nodeId: string,\n fieldAllow?: readonly string[],\n studio: StudioRegistry = getStudioRegistry(),\n): InsertContext | null {\n const root = builder.present\n const node = getNode(root, nodeId)\n if (!node) return null\n const manifest = studio.registry.getManifest(node.componentId)\n const childSlot =\n manifest?.slots.find((s) => s.name === 'children') ?? manifest?.slots.find((s) => s.name !== 'outlet')\n const ctx: InsertContext = childSlot\n ? {\n target: { parentId: nodeId, slot: childSlot.name },\n slotAllow: manifest!.slots.find((s) => s.name === childSlot.name)?.allow,\n destinationLabel: `${shortId(node.componentId)} · ${childSlot.name}`,\n }\n : resolveSiblingContext(studio, root, nodeId)\n\n // The field vocabulary governs only the top level (a direct child of the page\n // root). `length > 0` matches the validator, which treats an empty allow as\n // \"allow any\" (validate.ts).\n if (fieldAllow && fieldAllow.length > 0 && ctx.target.parentId === root.id) {\n return { ...ctx, slotAllow: intersectAllow(ctx.slotAllow, fieldAllow) }\n }\n return ctx\n}\n\n/** Sibling-or-root insert context for a node with no insertable child slot. */\nfunction resolveSiblingContext(studio: StudioRegistry, root: ComponentNode, nodeId: string): InsertContext {\n const info = getParentInfo(root, nodeId)\n if (info) {\n const pManifest = studio.registry.getManifest(info.parent.componentId)\n const allow = pManifest?.slots.find((s) => s.name === info.slot)?.allow\n return {\n target: { parentId: info.parent.id, slot: info.slot, index: info.index + 1 },\n slotAllow: allow,\n destinationLabel: `${shortId(info.parent.componentId)} · ${info.slot}`,\n }\n }\n return { target: { parentId: nodeId, slot: 'children' }, destinationLabel: 'root' }\n}\n\n/**\n * An in-canvas insertion position relative to a reference node (EC-405) — the\n * DnD position vocabulary (`lib/dnd/drop-rules`), narrowed to the three the\n * insertion affordances offer: `before`/`after` a sibling, or `into` an (empty)\n * container. `left`/`right` (the wrap-into-columns gesture) are DnD-only.\n */\nexport type InsertPosition = 'before' | 'after' | 'into'\n\n/**\n * Resolve an `InsertContext` for an EXPLICIT position relative to `nodeId`\n * (EC-405) — the position-carrying twin of {@link resolveInsertContext}, so an\n * affordance click at a chosen edge lands the insert at exactly that spot rather\n * than the panel re-deriving it from the selection.\n *\n * The slot resolution reuses the shared DnD rules — `into` uses\n * {@link primaryInsertSlot} (the same slot a drop `into` picks; `null` when the\n * node accepts no children), `before`/`after` use the reference's parent slot at\n * `index`/`index + 1` — so this surface and drag-insert can never disagree about\n * where a position lands or what it allows. `fieldAllow` (EC-188) still constrains\n * ONLY a direct child of the composition root, mirroring `resolveInsertContext`.\n */\nexport function resolveInsertContextAt(\n builder: { present: ComponentNode },\n nodeId: string,\n position: InsertPosition,\n fieldAllow?: readonly string[],\n studio: StudioRegistry = getStudioRegistry(),\n): InsertContext | null {\n const root = builder.present\n const node = getNode(root, nodeId)\n if (!node) return null\n\n let ctx: InsertContext\n if (position === 'into') {\n const manifest = studio.registry.getManifest(node.componentId)\n const slot = primaryInsertSlot(manifest)\n if (!slot) return null // the node accepts no children — no `into` position\n ctx = {\n target: {\n parentId: nodeId,\n slot: slot.name,\n index: (node.slots?.[slot.name] ?? []).length,\n },\n slotAllow: slot.allow,\n destinationLabel: `${shortId(node.componentId)} · ${slot.name}`,\n }\n } else {\n const info = getParentInfo(root, nodeId)\n if (!info) return null // the root has no siblings — no before/after position\n const pManifest = studio.registry.getManifest(info.parent.componentId)\n ctx = {\n target: {\n parentId: info.parent.id,\n slot: info.slot,\n index: info.index + (position === 'after' ? 1 : 0),\n },\n slotAllow: pManifest?.slots.find((s) => s.name === info.slot)?.allow,\n destinationLabel: `${shortId(info.parent.componentId)} · ${info.slot}`,\n }\n }\n\n if (fieldAllow && fieldAllow.length > 0 && ctx.target.parentId === root.id) {\n return { ...ctx, slotAllow: intersectAllow(ctx.slotAllow, fieldAllow) }\n }\n return ctx\n}\n\n/**\n * Which affordance positions are actually insertable at `nodeId` (EC-405 scope 3):\n * a position is offered only when it structurally exists AND at least one registry\n * component may land there (the SAME slot-rule filter the tree/DnD and the blocks\n * panel apply — {@link hasInsertableComponent} over the resolved `slotAllow`), so a\n * blocked spot shows no dead button.\n *\n * An EMPTY, insertable container surfaces ONLY `into` — the meaningful action there\n * is \"add the first child\", and an empty container often renders zero-height, so\n * its own before/after edges are degenerate and would pile the three \"+\" buttons\n * onto one point. A filled container and every leaf get before/after instead (each\n * child already carries its own edge affordances, so a filled container needs no\n * center \"+\").\n */\nexport function insertablePositions(\n builder: { present: ComponentNode },\n nodeId: string,\n fieldAllow?: readonly string[],\n studio: StudioRegistry = getStudioRegistry(),\n): InsertPosition[] {\n // Empty insertable container → into only.\n const into = resolveInsertContextAt(builder, nodeId, 'into', fieldAllow, studio)\n if (into && (into.target.index ?? 0) === 0 && hasInsertableComponent(studio, into.slotAllow)) {\n return ['into']\n }\n // Otherwise the sibling edges that accept something.\n const out: InsertPosition[] = []\n for (const position of ['before', 'after'] as const) {\n const ctx = resolveInsertContextAt(builder, nodeId, position, fieldAllow, studio)\n if (ctx && hasInsertableComponent(studio, ctx.slotAllow)) out.push(position)\n }\n return out\n}\n"]}
@@ -0,0 +1,60 @@
1
+ import type { ComponentNode } from '@elytracms/core/project-graph';
2
+ import type { ComponentManifest } from '@elytracms/core/component-registry';
3
+ /**
4
+ * Node display titles (EC-407) — the ONE helper behind both the page-tree row and
5
+ * the EC-404 selection-overlay frame label.
6
+ *
7
+ * A placed instance shows a derived **instance title** (its own text content, e.g. a
8
+ * heading's words) as the primary label, with the localized **component name** as
9
+ * muted secondary context; a node with no derivable instance title shows the
10
+ * component name alone. This replaces the old tree row's redundant `shortId` (mono
11
+ * id) + `title` pair — the raw id stays one hover away in the inspector header +
12
+ * the tree row `title=` attribute, so agent-inspectability is not lost.
13
+ *
14
+ * Placement: this lives in the studio (not `component-registry`) because the
15
+ * cheap rich-text head reuses `@elytracms/core/rich-text` `richTextPlainText`,
16
+ * which the DAG bars `component-registry` from importing. The pure derivation is
17
+ * mirrored from the CMS `resolveDocumentTitle` pattern (declared field/function +
18
+ * fallback), not shared with it.
19
+ */
20
+ /**
21
+ * Prop names, in priority order, whose value best titles an instance (EC-407). The
22
+ * name-affinity phase of {@link deriveInstanceTitle}: identity-ish props first
23
+ * (`title`/`heading`/`label`), then generic text (`text`/`name`/`value`), then media
24
+ * captions (`caption`/`alt`, so an Image titles by its alt text, not its URL `src`).
25
+ */
26
+ export declare const INSTANCE_TITLE_AFFINITY: readonly ["title", "heading", "label", "text", "name", "value", "caption", "alt"];
27
+ /** Max characters of a derived instance title before it is truncated for a row/frame. */
28
+ export declare const INSTANCE_TITLE_MAX = 64;
29
+ export interface NodeDisplayTitle {
30
+ /** The main label a surface shows: the instance title when derivable, else the component name. */
31
+ primary: string;
32
+ /** The localized component name — shown as muted secondary context ONLY when an instance title distinguishes `primary`. */
33
+ secondary?: string;
34
+ /** The localized component TYPE name (always present). */
35
+ componentName: string;
36
+ /** The derived per-instance title (already truncated), when one could be derived. */
37
+ instanceTitle?: string;
38
+ }
39
+ /**
40
+ * Derive a placed instance's raw (untruncated) title from its effective prop values
41
+ * (EC-407), or `undefined` when nothing text-ish is set. Pure; mirrors the CMS
42
+ * `resolveDocumentTitle` precedence:
43
+ *
44
+ * 1. A declared `nodeTitle` **function** — composed from the values (throws are
45
+ * swallowed so a bad pattern never breaks the tree).
46
+ * 2. A declared `nodeTitle` **prop name** — that prop's display text.
47
+ * 3. **Name-affinity heuristic** — the first {@link INSTANCE_TITLE_AFFINITY} prop
48
+ * that carries display text.
49
+ * 4. **Fallback** — the first declared text-ish prop (declaration order) with a
50
+ * non-URL value.
51
+ */
52
+ export declare function deriveInstanceTitle(manifest: ComponentManifest | undefined, node: ComponentNode): string | undefined;
53
+ /**
54
+ * The unified node label (EC-407) feeding the page-tree row AND the EC-404 overlay
55
+ * frame. `primary` is the derived instance title when one exists (truncated), else
56
+ * the localized component name; `secondary` is the localized component name, present
57
+ * only when an instance title distinguishes the row. `locale` is optional — omit it
58
+ * to resolve the component name against the active studio-UI locale.
59
+ */
60
+ export declare function nodeDisplayTitle(manifest: ComponentManifest | undefined, node: ComponentNode, locale?: string): NodeDisplayTitle;
@@ -0,0 +1,135 @@
1
+ import { effectivePropValues } from '@elytracms/core/component-registry';
2
+ import { richTextPlainText } from '@elytracms/core/rich-text';
3
+ import { componentDisplayName } from '@elytracms/studio/lib/persona/labels';
4
+ /**
5
+ * Node display titles (EC-407) — the ONE helper behind both the page-tree row and
6
+ * the EC-404 selection-overlay frame label.
7
+ *
8
+ * A placed instance shows a derived **instance title** (its own text content, e.g. a
9
+ * heading's words) as the primary label, with the localized **component name** as
10
+ * muted secondary context; a node with no derivable instance title shows the
11
+ * component name alone. This replaces the old tree row's redundant `shortId` (mono
12
+ * id) + `title` pair — the raw id stays one hover away in the inspector header +
13
+ * the tree row `title=` attribute, so agent-inspectability is not lost.
14
+ *
15
+ * Placement: this lives in the studio (not `component-registry`) because the
16
+ * cheap rich-text head reuses `@elytracms/core/rich-text` `richTextPlainText`,
17
+ * which the DAG bars `component-registry` from importing. The pure derivation is
18
+ * mirrored from the CMS `resolveDocumentTitle` pattern (declared field/function +
19
+ * fallback), not shared with it.
20
+ */
21
+ /**
22
+ * Prop names, in priority order, whose value best titles an instance (EC-407). The
23
+ * name-affinity phase of {@link deriveInstanceTitle}: identity-ish props first
24
+ * (`title`/`heading`/`label`), then generic text (`text`/`name`/`value`), then media
25
+ * captions (`caption`/`alt`, so an Image titles by its alt text, not its URL `src`).
26
+ */
27
+ export const INSTANCE_TITLE_AFFINITY = [
28
+ 'title',
29
+ 'heading',
30
+ 'label',
31
+ 'text',
32
+ 'name',
33
+ 'value',
34
+ 'caption',
35
+ 'alt',
36
+ ];
37
+ /** Max characters of a derived instance title before it is truncated for a row/frame. */
38
+ export const INSTANCE_TITLE_MAX = 64;
39
+ /** A value that looks like a URL / path / protocol — never a useful title. */
40
+ const URL_LIKE = /^(https?:\/\/|\/|#|mailto:|tel:|data:)/i;
41
+ /**
42
+ * Non-empty display text a text-ish prop value carries, else `undefined`.
43
+ * - `text` props: the trimmed string (rejected when `rejectUrl` and it looks URL-ish).
44
+ * - `richText` props: the cheap first-run plain-text head (`richTextPlainText`).
45
+ * - everything else (number/boolean/date/select/relation/asset/blocks/object): not text-ish.
46
+ */
47
+ function propDisplayText(field, value, rejectUrl) {
48
+ if (field.type === 'text') {
49
+ if (typeof value !== 'string')
50
+ return undefined;
51
+ const trimmed = value.trim();
52
+ if (trimmed.length === 0)
53
+ return undefined;
54
+ if (rejectUrl && URL_LIKE.test(trimmed))
55
+ return undefined;
56
+ return trimmed;
57
+ }
58
+ if (field.type === 'richText') {
59
+ const head = richTextPlainText(value)?.trim();
60
+ return head && head.length > 0 ? head : undefined;
61
+ }
62
+ return undefined;
63
+ }
64
+ /**
65
+ * Derive a placed instance's raw (untruncated) title from its effective prop values
66
+ * (EC-407), or `undefined` when nothing text-ish is set. Pure; mirrors the CMS
67
+ * `resolveDocumentTitle` precedence:
68
+ *
69
+ * 1. A declared `nodeTitle` **function** — composed from the values (throws are
70
+ * swallowed so a bad pattern never breaks the tree).
71
+ * 2. A declared `nodeTitle` **prop name** — that prop's display text.
72
+ * 3. **Name-affinity heuristic** — the first {@link INSTANCE_TITLE_AFFINITY} prop
73
+ * that carries display text.
74
+ * 4. **Fallback** — the first declared text-ish prop (declaration order) with a
75
+ * non-URL value.
76
+ */
77
+ export function deriveInstanceTitle(manifest, node) {
78
+ if (!manifest)
79
+ return undefined;
80
+ const values = effectivePropValues(manifest, node);
81
+ const declared = manifest.nodeTitle;
82
+ if (typeof declared === 'function') {
83
+ try {
84
+ const out = declared(values);
85
+ if (typeof out === 'string' && out.trim())
86
+ return out.trim();
87
+ }
88
+ catch {
89
+ // A throwing title pattern must never break the editor — fall through.
90
+ }
91
+ }
92
+ else if (typeof declared === 'string') {
93
+ const field = manifest.props[declared];
94
+ const text = field ? propDisplayText(field, values[declared], false) : undefined;
95
+ if (text)
96
+ return text;
97
+ }
98
+ for (const name of INSTANCE_TITLE_AFFINITY) {
99
+ const field = manifest.props[name];
100
+ if (!field)
101
+ continue;
102
+ const text = propDisplayText(field, values[name], false);
103
+ if (text)
104
+ return text;
105
+ }
106
+ for (const [name, field] of Object.entries(manifest.props)) {
107
+ const text = propDisplayText(field, values[name], true);
108
+ if (text)
109
+ return text;
110
+ }
111
+ return undefined;
112
+ }
113
+ /** Truncate a title for a compact row / frame bar, appending an ellipsis. */
114
+ function truncate(value, max) {
115
+ return value.length <= max ? value : `${value.slice(0, max - 1).trimEnd()}…`;
116
+ }
117
+ /**
118
+ * The unified node label (EC-407) feeding the page-tree row AND the EC-404 overlay
119
+ * frame. `primary` is the derived instance title when one exists (truncated), else
120
+ * the localized component name; `secondary` is the localized component name, present
121
+ * only when an instance title distinguishes the row. `locale` is optional — omit it
122
+ * to resolve the component name against the active studio-UI locale.
123
+ */
124
+ export function nodeDisplayTitle(manifest, node, locale) {
125
+ const componentName = componentDisplayName(node.componentId, manifest, locale);
126
+ const raw = deriveInstanceTitle(manifest, node);
127
+ const instanceTitle = raw ? truncate(raw, INSTANCE_TITLE_MAX) : undefined;
128
+ return {
129
+ componentName,
130
+ instanceTitle,
131
+ primary: instanceTitle ?? componentName,
132
+ secondary: instanceTitle ? componentName : undefined,
133
+ };
134
+ }
135
+ //# sourceMappingURL=node-title.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node-title.js","sourceRoot":"","sources":["../../../src/components/builder/node-title.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ComponentNode } from '@elytracms/core/project-graph'\nimport type { ComponentManifest, PropField } from '@elytracms/core/component-registry'\nimport { effectivePropValues } from '@elytracms/core/component-registry'\nimport { richTextPlainText } from '@elytracms/core/rich-text'\nimport { componentDisplayName } from '@elytracms/studio/lib/persona/labels'\n\n/**\n * Node display titles (EC-407) — the ONE helper behind both the page-tree row and\n * the EC-404 selection-overlay frame label.\n *\n * A placed instance shows a derived **instance title** (its own text content, e.g. a\n * heading's words) as the primary label, with the localized **component name** as\n * muted secondary context; a node with no derivable instance title shows the\n * component name alone. This replaces the old tree row's redundant `shortId` (mono\n * id) + `title` pair — the raw id stays one hover away in the inspector header +\n * the tree row `title=` attribute, so agent-inspectability is not lost.\n *\n * Placement: this lives in the studio (not `component-registry`) because the\n * cheap rich-text head reuses `@elytracms/core/rich-text` `richTextPlainText`,\n * which the DAG bars `component-registry` from importing. The pure derivation is\n * mirrored from the CMS `resolveDocumentTitle` pattern (declared field/function +\n * fallback), not shared with it.\n */\n\n/**\n * Prop names, in priority order, whose value best titles an instance (EC-407). The\n * name-affinity phase of {@link deriveInstanceTitle}: identity-ish props first\n * (`title`/`heading`/`label`), then generic text (`text`/`name`/`value`), then media\n * captions (`caption`/`alt`, so an Image titles by its alt text, not its URL `src`).\n */\nexport const INSTANCE_TITLE_AFFINITY = [\n 'title',\n 'heading',\n 'label',\n 'text',\n 'name',\n 'value',\n 'caption',\n 'alt',\n] as const\n\n/** Max characters of a derived instance title before it is truncated for a row/frame. */\nexport const INSTANCE_TITLE_MAX = 64\n\n/** A value that looks like a URL / path / protocol — never a useful title. */\nconst URL_LIKE = /^(https?:\\/\\/|\\/|#|mailto:|tel:|data:)/i\n\nexport interface NodeDisplayTitle {\n /** The main label a surface shows: the instance title when derivable, else the component name. */\n primary: string\n /** The localized component name — shown as muted secondary context ONLY when an instance title distinguishes `primary`. */\n secondary?: string\n /** The localized component TYPE name (always present). */\n componentName: string\n /** The derived per-instance title (already truncated), when one could be derived. */\n instanceTitle?: string\n}\n\n/**\n * Non-empty display text a text-ish prop value carries, else `undefined`.\n * - `text` props: the trimmed string (rejected when `rejectUrl` and it looks URL-ish).\n * - `richText` props: the cheap first-run plain-text head (`richTextPlainText`).\n * - everything else (number/boolean/date/select/relation/asset/blocks/object): not text-ish.\n */\nfunction propDisplayText(field: PropField, value: unknown, rejectUrl: boolean): string | undefined {\n if (field.type === 'text') {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n if (trimmed.length === 0) return undefined\n if (rejectUrl && URL_LIKE.test(trimmed)) return undefined\n return trimmed\n }\n if (field.type === 'richText') {\n const head = richTextPlainText(value)?.trim()\n return head && head.length > 0 ? head : undefined\n }\n return undefined\n}\n\n/**\n * Derive a placed instance's raw (untruncated) title from its effective prop values\n * (EC-407), or `undefined` when nothing text-ish is set. Pure; mirrors the CMS\n * `resolveDocumentTitle` precedence:\n *\n * 1. A declared `nodeTitle` **function** — composed from the values (throws are\n * swallowed so a bad pattern never breaks the tree).\n * 2. A declared `nodeTitle` **prop name** — that prop's display text.\n * 3. **Name-affinity heuristic** — the first {@link INSTANCE_TITLE_AFFINITY} prop\n * that carries display text.\n * 4. **Fallback** — the first declared text-ish prop (declaration order) with a\n * non-URL value.\n */\nexport function deriveInstanceTitle(\n manifest: ComponentManifest | undefined,\n node: ComponentNode,\n): string | undefined {\n if (!manifest) return undefined\n const values = effectivePropValues(manifest, node)\n\n const declared = manifest.nodeTitle\n if (typeof declared === 'function') {\n try {\n const out = declared(values)\n if (typeof out === 'string' && out.trim()) return out.trim()\n } catch {\n // A throwing title pattern must never break the editor — fall through.\n }\n } else if (typeof declared === 'string') {\n const field = manifest.props[declared]\n const text = field ? propDisplayText(field, values[declared], false) : undefined\n if (text) return text\n }\n\n for (const name of INSTANCE_TITLE_AFFINITY) {\n const field = manifest.props[name]\n if (!field) continue\n const text = propDisplayText(field, values[name], false)\n if (text) return text\n }\n\n for (const [name, field] of Object.entries(manifest.props)) {\n const text = propDisplayText(field, values[name], true)\n if (text) return text\n }\n\n return undefined\n}\n\n/** Truncate a title for a compact row / frame bar, appending an ellipsis. */\nfunction truncate(value: string, max: number): string {\n return value.length <= max ? value : `${value.slice(0, max - 1).trimEnd()}…`\n}\n\n/**\n * The unified node label (EC-407) feeding the page-tree row AND the EC-404 overlay\n * frame. `primary` is the derived instance title when one exists (truncated), else\n * the localized component name; `secondary` is the localized component name, present\n * only when an instance title distinguishes the row. `locale` is optional — omit it\n * to resolve the component name against the active studio-UI locale.\n */\nexport function nodeDisplayTitle(\n manifest: ComponentManifest | undefined,\n node: ComponentNode,\n locale?: string,\n): NodeDisplayTitle {\n const componentName = componentDisplayName(node.componentId, manifest, locale)\n const raw = deriveInstanceTitle(manifest, node)\n const instanceTitle = raw ? truncate(raw, INSTANCE_TITLE_MAX) : undefined\n return {\n componentName,\n instanceTitle,\n primary: instanceTitle ?? componentName,\n secondary: instanceTitle ? componentName : undefined,\n }\n}\n"]}