@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.
- package/convex/assets.ts +11 -0
- package/convex/delivery.ts +5 -0
- package/convex/schema.ts +23 -0
- package/dist/components/assets/asset-thumb.d.ts +4 -2
- package/dist/components/assets/asset-thumb.js +26 -4
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-module.js +48 -2
- package/dist/components/assets/assets-module.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +11 -1
- package/dist/components/assets/assets-store.js +18 -1
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/builder/blocks-model.d.ts +11 -0
- package/dist/components/builder/blocks-model.js +26 -8
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/canvas-chrome-css.d.ts +54 -0
- package/dist/components/builder/canvas-chrome-css.js +246 -0
- package/dist/components/builder/canvas-chrome-css.js.map +1 -0
- package/dist/components/builder/canvas-chrome.d.ts +122 -18
- package/dist/components/builder/canvas-chrome.js +148 -82
- package/dist/components/builder/canvas-chrome.js.map +1 -1
- package/dist/components/builder/canvas-inline-editing.d.ts +7 -0
- package/dist/components/builder/canvas-inline-editing.js +3 -1
- package/dist/components/builder/canvas-inline-editing.js.map +1 -1
- package/dist/components/builder/canvas-overlay-geometry.d.ts +72 -0
- package/dist/components/builder/canvas-overlay-geometry.js +80 -0
- package/dist/components/builder/canvas-overlay-geometry.js.map +1 -0
- package/dist/components/builder/canvas-rect-tracking.d.ts +124 -0
- package/dist/components/builder/canvas-rect-tracking.js +198 -0
- package/dist/components/builder/canvas-rect-tracking.js.map +1 -0
- package/dist/components/builder/canvas-render-context.d.ts +13 -0
- package/dist/components/builder/canvas-render-context.js +7 -5
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/canvas-stylesheet.d.ts +1 -1
- package/dist/components/builder/canvas-stylesheet.js +16 -9
- package/dist/components/builder/canvas-stylesheet.js.map +1 -1
- package/dist/components/builder/canvas-theme-notice.d.ts +6 -0
- package/dist/components/builder/canvas-theme-notice.js +46 -0
- package/dist/components/builder/canvas-theme-notice.js.map +1 -0
- package/dist/components/builder/canvas.js +78 -22
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/composition-field-editor.js +22 -3
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/delete-key.d.ts +38 -0
- package/dist/components/builder/delete-key.js +46 -0
- package/dist/components/builder/delete-key.js.map +1 -0
- package/dist/components/builder/drag-session.d.ts +8 -0
- package/dist/components/builder/drag-session.js +15 -0
- package/dist/components/builder/drag-session.js.map +1 -1
- package/dist/components/builder/insert-affordance-context.d.ts +19 -0
- package/dist/components/builder/insert-affordance-context.js +7 -0
- package/dist/components/builder/insert-affordance-context.js.map +1 -0
- package/dist/components/builder/insert-context.d.ts +40 -0
- package/dist/components/builder/insert-context.js +86 -3
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/node-title.d.ts +60 -0
- package/dist/components/builder/node-title.js +135 -0
- package/dist/components/builder/node-title.js.map +1 -0
- package/dist/components/builder/page-tree.js +21 -3
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/prop-selection.d.ts +52 -0
- package/dist/components/builder/prop-selection.js +38 -0
- package/dist/components/builder/prop-selection.js.map +1 -0
- package/dist/components/builder/prop-sync-context.d.ts +49 -0
- package/dist/components/builder/prop-sync-context.js +23 -0
- package/dist/components/builder/prop-sync-context.js.map +1 -0
- package/dist/components/builder/property-inspector.js +23 -1
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/reuse-context.js +1 -1
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/rich-text-toolbar.d.ts +23 -0
- package/dist/components/builder/rich-text-toolbar.js +24 -17
- package/dist/components/builder/rich-text-toolbar.js.map +1 -1
- package/dist/components/builder/selection-overlay.d.ts +79 -0
- package/dist/components/builder/selection-overlay.js +244 -0
- package/dist/components/builder/selection-overlay.js.map +1 -0
- package/dist/components/builder/spacing-handles.d.ts +88 -1
- package/dist/components/builder/spacing-handles.js +191 -63
- package/dist/components/builder/spacing-handles.js.map +1 -1
- package/dist/components/cms/asset-picker.js +11 -5
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/rich-text/block-style.d.ts +48 -0
- package/dist/components/cms/rich-text/block-style.js +30 -0
- package/dist/components/cms/rich-text/block-style.js.map +1 -0
- package/dist/components/cms/rich-text/embed-picker.js +4 -3
- package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
- package/dist/components/cms/rich-text/extensions.d.ts +2 -0
- package/dist/components/cms/rich-text/extensions.js +4 -0
- package/dist/components/cms/rich-text/extensions.js.map +1 -1
- package/dist/components/cms/rich-text/font-size-control.d.ts +31 -0
- package/dist/components/cms/rich-text/font-size-control.js +40 -0
- package/dist/components/cms/rich-text/font-size-control.js.map +1 -0
- package/dist/components/cms/rich-text/font-size.d.ts +16 -0
- package/dist/components/cms/rich-text/font-size.js +40 -0
- package/dist/components/cms/rich-text/font-size.js.map +1 -0
- package/dist/components/cms/rich-text/toolbar-state.d.ts +3 -0
- package/dist/components/cms/rich-text/toolbar-state.js +3 -0
- package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
- package/dist/components/cms/rich-text/toolbar.d.ts +13 -0
- package/dist/components/cms/rich-text/toolbar.js +17 -5
- package/dist/components/cms/rich-text/toolbar.js.map +1 -1
- package/dist/components/components/component-detail.js +3 -3
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/component-preview.d.ts +2 -1
- package/dist/components/components/component-preview.js +25 -26
- package/dist/components/components/component-preview.js.map +1 -1
- package/dist/components/components/registry-browser.js +2 -2
- package/dist/components/components/registry-browser.js.map +1 -1
- package/dist/components/components/taxonomy.js +3 -2
- package/dist/components/components/taxonomy.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +1 -1
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/lib/assets/backfill-thumbs.d.ts +67 -0
- package/dist/lib/assets/backfill-thumbs.js +120 -0
- package/dist/lib/assets/backfill-thumbs.js.map +1 -0
- package/dist/lib/assets/delete-asset.d.ts +2 -1
- package/dist/lib/assets/delete-asset.js +12 -0
- package/dist/lib/assets/delete-asset.js.map +1 -1
- package/dist/lib/assets/index.d.ts +2 -0
- package/dist/lib/assets/index.js +2 -0
- package/dist/lib/assets/index.js.map +1 -1
- package/dist/lib/assets/thumbnail.d.ts +145 -0
- package/dist/lib/assets/thumbnail.js +220 -0
- package/dist/lib/assets/thumbnail.js.map +1 -0
- package/dist/lib/assets/upload.d.ts +9 -0
- package/dist/lib/assets/upload.js +31 -0
- package/dist/lib/assets/upload.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +20 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +18 -0
- package/dist/lib/i18n/messages.en.js +21 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/persistence/backend-mode.d.ts +7 -0
- package/dist/lib/persistence/backend-mode.js +9 -0
- package/dist/lib/persistence/backend-mode.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +4 -0
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/index.d.ts +1 -1
- package/dist/lib/persistence/index.js +1 -1
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +9 -5
- package/dist/lib/persona/labels.js +13 -8
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/references/references-store.js +7 -1
- package/dist/lib/references/references-store.js.map +1 -1
- package/package.json +2 -2
- 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;
|
|
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 {
|
|
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":"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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 { 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"]}
|