@elytracms/studio 0.0.36 → 0.0.37

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 (42) hide show
  1. package/dist/components/cms/field-control.js +2 -1
  2. package/dist/components/cms/field-control.js.map +1 -1
  3. package/dist/components/cms/rich-text/asset-image.d.ts +12 -0
  4. package/dist/components/cms/rich-text/asset-image.js +24 -0
  5. package/dist/components/cms/rich-text/asset-image.js.map +1 -0
  6. package/dist/components/cms/rich-text/component-embed.d.ts +15 -0
  7. package/dist/components/cms/rich-text/component-embed.js +54 -0
  8. package/dist/components/cms/rich-text/component-embed.js.map +1 -0
  9. package/dist/components/cms/rich-text/editor.d.ts +24 -0
  10. package/dist/components/cms/rich-text/editor.js +93 -0
  11. package/dist/components/cms/rich-text/editor.js.map +1 -0
  12. package/dist/components/cms/rich-text/embed-control.d.ts +17 -0
  13. package/dist/components/cms/rich-text/embed-control.js +62 -0
  14. package/dist/components/cms/rich-text/embed-control.js.map +1 -0
  15. package/dist/components/cms/rich-text/extensions.d.ts +23 -0
  16. package/dist/components/cms/rich-text/extensions.js +40 -0
  17. package/dist/components/cms/rich-text/extensions.js.map +1 -0
  18. package/dist/components/cms/rich-text/image-control.d.ts +16 -0
  19. package/dist/components/cms/rich-text/image-control.js +36 -0
  20. package/dist/components/cms/rich-text/image-control.js.map +1 -0
  21. package/dist/components/cms/rich-text/index.d.ts +7 -0
  22. package/dist/components/cms/rich-text/index.js +8 -0
  23. package/dist/components/cms/rich-text/index.js.map +1 -0
  24. package/dist/components/cms/rich-text/link-control.d.ts +22 -0
  25. package/dist/components/cms/rich-text/link-control.js +65 -0
  26. package/dist/components/cms/rich-text/link-control.js.map +1 -0
  27. package/dist/components/cms/rich-text/ref-link.d.ts +14 -0
  28. package/dist/components/cms/rich-text/ref-link.js +26 -0
  29. package/dist/components/cms/rich-text/ref-link.js.map +1 -0
  30. package/dist/components/cms/rich-text/toolbar-button.d.ts +15 -0
  31. package/dist/components/cms/rich-text/toolbar-button.js +11 -0
  32. package/dist/components/cms/rich-text/toolbar-button.js.map +1 -0
  33. package/dist/components/cms/rich-text/toolbar.d.ts +20 -0
  34. package/dist/components/cms/rich-text/toolbar.js +40 -0
  35. package/dist/components/cms/rich-text/toolbar.js.map +1 -0
  36. package/dist/components/cms/rich-text/value.d.ts +33 -0
  37. package/dist/components/cms/rich-text/value.js +114 -0
  38. package/dist/components/cms/rich-text/value.js.map +1 -0
  39. package/dist/lib/i18n/messages.en.js +33 -0
  40. package/dist/lib/i18n/messages.en.js.map +1 -1
  41. package/package.json +8 -2
  42. package/src/styles/studio.css +105 -0
@@ -0,0 +1,15 @@
1
+ import { Node } from '@tiptap/react';
2
+ /**
3
+ * The rich-text component-embed node (EC-189/EC-314): a block atom holding a single
4
+ * composition value at `attrs.node`. It is the third place constrained composition
5
+ * lives (after the page collection and `blocks` fields) — a registry component
6
+ * embedded in prose. The node carries no ProseMirror content (the embedded component
7
+ * owns its own children through composition slots); `attrs.node` round-trips verbatim
8
+ * so the extractor still indexes the embedded component's references, and the delivery
9
+ * renderer renders it exactly as a `blocks` value.
10
+ *
11
+ * The editor shows a read-only placeholder card naming the embedded component (a live
12
+ * in-editor preview would need the runtime renderer + resolvers; deferred). Insert and
13
+ * deeper prop editing are wired from the toolbar / inspector.
14
+ */
15
+ export declare const ComponentEmbed: Node<any, any>;
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Node, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes } from '@tiptap/react';
3
+ import { Blocks, Trash2 } from 'lucide-react';
4
+ import { COMPONENT_EMBED_NODE_TYPE, embeddedComponentNode } from '@elytracms/core/rich-text';
5
+ import { Button } from '@elytracms/studio/ui';
6
+ import { translate } from '@elytracms/studio/lib/i18n';
7
+ import { cn } from '@elytracms/studio/lib/utils';
8
+ /**
9
+ * The rich-text component-embed node (EC-189/EC-314): a block atom holding a single
10
+ * composition value at `attrs.node`. It is the third place constrained composition
11
+ * lives (after the page collection and `blocks` fields) — a registry component
12
+ * embedded in prose. The node carries no ProseMirror content (the embedded component
13
+ * owns its own children through composition slots); `attrs.node` round-trips verbatim
14
+ * so the extractor still indexes the embedded component's references, and the delivery
15
+ * renderer renders it exactly as a `blocks` value.
16
+ *
17
+ * The editor shows a read-only placeholder card naming the embedded component (a live
18
+ * in-editor preview would need the runtime renderer + resolvers; deferred). Insert and
19
+ * deeper prop editing are wired from the toolbar / inspector.
20
+ */
21
+ export const ComponentEmbed = Node.create({
22
+ name: COMPONENT_EMBED_NODE_TYPE,
23
+ group: 'block',
24
+ atom: true,
25
+ selectable: true,
26
+ draggable: true,
27
+ addAttributes() {
28
+ // `node` holds the embedded ComponentNode JSON; storage/JSON only, not markup.
29
+ return { node: { default: null, rendered: false } };
30
+ },
31
+ parseHTML() {
32
+ return [{ tag: `div[data-${COMPONENT_EMBED_NODE_TYPE}]` }];
33
+ },
34
+ renderHTML({ HTMLAttributes }) {
35
+ return ['div', mergeAttributes(HTMLAttributes, { [`data-${COMPONENT_EMBED_NODE_TYPE}`]: '' })];
36
+ },
37
+ addNodeView() {
38
+ return ReactNodeViewRenderer(ComponentEmbedView);
39
+ },
40
+ });
41
+ /** Read the embedded ComponentNode from a ProseMirror node's attrs, if valid. */
42
+ function readEmbedded(attrNode) {
43
+ return embeddedComponentNode({
44
+ type: COMPONENT_EMBED_NODE_TYPE,
45
+ attrs: { node: attrNode },
46
+ });
47
+ }
48
+ function ComponentEmbedView({ node, deleteNode, selected, editor }) {
49
+ const embedded = readEmbedded(node.attrs.node);
50
+ const componentId = embedded?.componentId ?? translate('richText.embed.unknown');
51
+ const editable = editor.isEditable;
52
+ return (_jsxs(NodeViewWrapper, { "data-drag-handle": true, className: cn('my-2 flex items-center gap-2.5 rounded-md border border-border bg-muted/40 px-3 py-2.5 text-sm', selected && 'ring-2 ring-ring/50'), children: [_jsx(Blocks, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "min-w-0 flex-1 truncate font-medium text-foreground", children: componentId }), _jsx("span", { className: "shrink-0 text-xs text-muted-foreground", children: translate('richText.embed.badge') }), editable ? (_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", contentEditable: false, "aria-label": translate('common.remove'), onClick: () => deleteNode(), children: _jsx(Trash2, { className: "size-3.5" }) })) : null] }));
53
+ }
54
+ //# sourceMappingURL=component-embed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"component-embed.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/component-embed.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE7F,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAA;AAE5F,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAC7C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,EAAE,EAAE,MAAM,6BAA6B,CAAA;AAEhD;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAC,MAAM,CAAC;IACxC,IAAI,EAAE,yBAAyB;IAC/B,KAAK,EAAE,OAAO;IACd,IAAI,EAAE,IAAI;IACV,UAAU,EAAE,IAAI;IAChB,SAAS,EAAE,IAAI;IAEf,aAAa;QACX,+EAA+E;QAC/E,OAAO,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAA;IACrD,CAAC;IAED,SAAS;QACP,OAAO,CAAC,EAAE,GAAG,EAAE,YAAY,yBAAyB,GAAG,EAAE,CAAC,CAAA;IAC5D,CAAC;IAED,UAAU,CAAC,EAAE,cAAc,EAAE;QAC3B,OAAO,CAAC,KAAK,EAAE,eAAe,CAAC,cAAc,EAAE,EAAE,CAAC,QAAQ,yBAAyB,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAA;IAChG,CAAC;IAED,WAAW;QACT,OAAO,qBAAqB,CAAC,kBAAkB,CAAC,CAAA;IAClD,CAAC;CACF,CAAC,CAAA;AAEF,iFAAiF;AACjF,SAAS,YAAY,CAAC,QAAiB;IACrC,OAAO,qBAAqB,CAAC;QAC3B,IAAI,EAAE,yBAAyB;QAC/B,KAAK,EAAE,EAAE,IAAI,EAAE,QAAqB,EAAE;KACvC,CAAC,CAAA;AACJ,CAAC;AAED,SAAS,kBAAkB,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAsB;IACpF,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IAC9C,MAAM,WAAW,GAAG,QAAQ,EAAE,WAAW,IAAI,SAAS,CAAC,wBAAwB,CAAC,CAAA;IAChF,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,CAAA;IAElC,OAAO,CACL,MAAC,eAAe,8BAEd,SAAS,EAAE,EAAE,CACX,gGAAgG,EAChG,QAAQ,IAAI,qBAAqB,CAClC,aAED,KAAC,MAAM,IAAC,SAAS,EAAC,uCAAuC,GAAG,EAC5D,eAAM,SAAS,EAAC,qDAAqD,YAAE,WAAW,GAAQ,EAC1F,eAAM,SAAS,EAAC,wCAAwC,YAAE,SAAS,CAAC,sBAAsB,CAAC,GAAQ,EAClG,QAAQ,CAAC,CAAC,CAAC,CACV,KAAC,MAAM,IACL,IAAI,EAAC,QAAQ,EACb,OAAO,EAAC,OAAO,EACf,IAAI,EAAC,SAAS,EACd,eAAe,EAAE,KAAK,gBACV,SAAS,CAAC,eAAe,CAAC,EACtC,OAAO,EAAE,GAAG,EAAE,CAAC,UAAU,EAAE,YAE3B,KAAC,MAAM,IAAC,SAAS,EAAC,UAAU,GAAG,GACxB,CACV,CAAC,CAAC,CAAC,IAAI,IACQ,CACnB,CAAA;AACH,CAAC","sourcesContent":["import { Node, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes } from '@tiptap/react'\nimport type { ReactNodeViewProps } from '@tiptap/react'\nimport { Blocks, Trash2 } from 'lucide-react'\nimport { COMPONENT_EMBED_NODE_TYPE, embeddedComponentNode } from '@elytracms/core/rich-text'\nimport type { ComponentNode, JsonValue } from '@elytracms/core/project-graph'\nimport { Button } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { cn } from '@elytracms/studio/lib/utils'\n\n/**\n * The rich-text component-embed node (EC-189/EC-314): a block atom holding a single\n * composition value at `attrs.node`. It is the third place constrained composition\n * lives (after the page collection and `blocks` fields) — a registry component\n * embedded in prose. The node carries no ProseMirror content (the embedded component\n * owns its own children through composition slots); `attrs.node` round-trips verbatim\n * so the extractor still indexes the embedded component's references, and the delivery\n * renderer renders it exactly as a `blocks` value.\n *\n * The editor shows a read-only placeholder card naming the embedded component (a live\n * in-editor preview would need the runtime renderer + resolvers; deferred). Insert and\n * deeper prop editing are wired from the toolbar / inspector.\n */\nexport const ComponentEmbed = Node.create({\n name: COMPONENT_EMBED_NODE_TYPE,\n group: 'block',\n atom: true,\n selectable: true,\n draggable: true,\n\n addAttributes() {\n // `node` holds the embedded ComponentNode JSON; storage/JSON only, not markup.\n return { node: { default: null, rendered: false } }\n },\n\n parseHTML() {\n return [{ tag: `div[data-${COMPONENT_EMBED_NODE_TYPE}]` }]\n },\n\n renderHTML({ HTMLAttributes }) {\n return ['div', mergeAttributes(HTMLAttributes, { [`data-${COMPONENT_EMBED_NODE_TYPE}`]: '' })]\n },\n\n addNodeView() {\n return ReactNodeViewRenderer(ComponentEmbedView)\n },\n})\n\n/** Read the embedded ComponentNode from a ProseMirror node's attrs, if valid. */\nfunction readEmbedded(attrNode: unknown): ComponentNode | undefined {\n return embeddedComponentNode({\n type: COMPONENT_EMBED_NODE_TYPE,\n attrs: { node: attrNode as JsonValue },\n })\n}\n\nfunction ComponentEmbedView({ node, deleteNode, selected, editor }: ReactNodeViewProps) {\n const embedded = readEmbedded(node.attrs.node)\n const componentId = embedded?.componentId ?? translate('richText.embed.unknown')\n const editable = editor.isEditable\n\n return (\n <NodeViewWrapper\n data-drag-handle\n className={cn(\n 'my-2 flex items-center gap-2.5 rounded-md border border-border bg-muted/40 px-3 py-2.5 text-sm',\n selected && 'ring-2 ring-ring/50',\n )}\n >\n <Blocks className=\"size-4 shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 flex-1 truncate font-medium text-foreground\">{componentId}</span>\n <span className=\"shrink-0 text-xs text-muted-foreground\">{translate('richText.embed.badge')}</span>\n {editable ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n contentEditable={false}\n aria-label={translate('common.remove')}\n onClick={() => deleteNode()}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n ) : null}\n </NodeViewWrapper>\n )\n}\n"]}
@@ -0,0 +1,24 @@
1
+ interface RichTextEditorProps {
2
+ /** The stored `richText` value (`{ version, doc }`, legacy string, or empty). */
3
+ value: unknown;
4
+ onChange: (value: unknown) => void;
5
+ readOnly?: boolean;
6
+ id?: string;
7
+ /** The field's embed `allow` list (component ids editors may embed). */
8
+ allow?: string[];
9
+ }
10
+ /**
11
+ * The CMS rich-text field editor (EC-314) — a real TipTap editor over the shared
12
+ * `@elytracms/core/rich-text` storage, replacing the plaintext textarea stub.
13
+ *
14
+ * It stays *controlled without churn*: the editor is created once from the initial
15
+ * value; the stored value is only re-pushed into ProseMirror when it changes
16
+ * externally (locale switch, reset, compare) and is not already equivalent to what
17
+ * is on screen. On edit, output is pruned to the lean storage convention
18
+ * ({@link normalizeEditorDoc}) and — crucially — when an edit lands the document back
19
+ * on its stored meaning, the *exact stored value* is re-emitted so the field never
20
+ * shows a spurious dirty state (the same "commit-nothing-when-equivalent" contract
21
+ * the canvas inline editor uses).
22
+ */
23
+ export declare function RichTextEditor({ value, onChange, readOnly, id, allow }: RichTextEditorProps): import("react").JSX.Element | null;
24
+ export {};
@@ -0,0 +1,93 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef } from 'react';
3
+ import { EditorContent, useEditor } from '@tiptap/react';
4
+ import { cn } from '@elytracms/studio/lib/utils';
5
+ import { localizedLabel } from '@elytracms/studio/lib/i18n';
6
+ import { useOptionalCollectionView } from '../../collection-view/collection-view-context';
7
+ import { useOptionalAssets } from '../../assets/assets-store';
8
+ import { buildRichTextExtensions } from './extensions';
9
+ import { isEffectivelyEmpty, isEquivalentToStored, normalizeEditorDoc, richTextValueFromDoc, toEditorDoc, } from './value';
10
+ import { RichTextToolbar } from './toolbar';
11
+ /**
12
+ * The CMS rich-text field editor (EC-314) — a real TipTap editor over the shared
13
+ * `@elytracms/core/rich-text` storage, replacing the plaintext textarea stub.
14
+ *
15
+ * It stays *controlled without churn*: the editor is created once from the initial
16
+ * value; the stored value is only re-pushed into ProseMirror when it changes
17
+ * externally (locale switch, reset, compare) and is not already equivalent to what
18
+ * is on screen. On edit, output is pruned to the lean storage convention
19
+ * ({@link normalizeEditorDoc}) and — crucially — when an edit lands the document back
20
+ * on its stored meaning, the *exact stored value* is re-emitted so the field never
21
+ * shows a spurious dirty state (the same "commit-nothing-when-equivalent" contract
22
+ * the canvas inline editor uses).
23
+ */
24
+ export function RichTextEditor({ value, onChange, readOnly = false, id, allow }) {
25
+ // Ambient context for reference-aware insertion (null outside a collection view /
26
+ // asset backend — the editor then degrades to external links and no image insert).
27
+ const source = useOptionalCollectionView();
28
+ const assets = useOptionalAssets();
29
+ // Every linkable document across the workspace (excluding the asset rail — you link
30
+ // to content, not to raw files). Recomputed only when the source changes.
31
+ const linkTargets = useMemo(() => {
32
+ if (!source)
33
+ return [];
34
+ const out = [];
35
+ for (const collection of source.collections) {
36
+ if (collection.id === source.assetCollectionId)
37
+ continue;
38
+ const collectionLabel = localizedLabel(collection.form?.labelSingular, collection.id);
39
+ for (const doc of source.referenceCandidates(collection.id)) {
40
+ const ref = { collection: collection.id, id: doc.id };
41
+ out.push({ ref, label: source.titleOf(ref), collectionLabel });
42
+ }
43
+ }
44
+ return out;
45
+ }, [source]);
46
+ // Latest value/onChange, read inside the stable onUpdate closure.
47
+ const valueRef = useRef(value);
48
+ valueRef.current = value;
49
+ const onChangeRef = useRef(onChange);
50
+ onChangeRef.current = onChange;
51
+ // What we last emitted upward — lets the sync effect ignore our own echoes.
52
+ const lastEmitted = useRef(value);
53
+ const editor = useEditor({
54
+ extensions: buildRichTextExtensions(),
55
+ content: toEditorDoc(value),
56
+ editable: !readOnly,
57
+ editorProps: {
58
+ attributes: { class: 'ec-rte-content', ...(id ? { id } : {}) },
59
+ },
60
+ onUpdate: ({ editor }) => {
61
+ const current = normalizeEditorDoc(editor.getJSON());
62
+ const stored = valueRef.current;
63
+ // Three outcomes: unchanged → re-emit the stored value verbatim (no dirty);
64
+ // emptied → clear the field (undefined); otherwise → the new stamped value.
65
+ const next = isEquivalentToStored(current, stored)
66
+ ? stored
67
+ : isEffectivelyEmpty(current)
68
+ ? undefined
69
+ : richTextValueFromDoc(current);
70
+ lastEmitted.current = next;
71
+ onChangeRef.current(next);
72
+ },
73
+ });
74
+ // Reflect external value changes into the editor, never echoing our own emits.
75
+ useEffect(() => {
76
+ if (!editor)
77
+ return;
78
+ if (value === lastEmitted.current)
79
+ return;
80
+ const current = normalizeEditorDoc(editor.getJSON());
81
+ if (isEquivalentToStored(current, value))
82
+ return;
83
+ editor.commands.setContent(toEditorDoc(value), { emitUpdate: false });
84
+ }, [editor, value]);
85
+ // Toggle editability without rebuilding the editor (preserves history/selection).
86
+ useEffect(() => {
87
+ editor?.setEditable(!readOnly);
88
+ }, [editor, readOnly]);
89
+ if (!editor)
90
+ return null;
91
+ return (_jsxs("div", { className: cn('ec-rte overflow-hidden rounded-md border border-input bg-background shadow-sm', 'focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/40', readOnly && 'opacity-70'), children: [!readOnly ? (_jsx(RichTextToolbar, { editor: editor, targets: linkTargets, assets: assets, allow: allow })) : null, _jsx(EditorContent, { editor: editor })] }));
92
+ }
93
+ //# sourceMappingURL=editor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"editor.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/editor.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAClD,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAExD,OAAO,EAAE,EAAE,EAAE,MAAM,6BAA6B,CAAA;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC3D,OAAO,EAAE,yBAAyB,EAAE,MAAM,+CAA+C,CAAA;AACzF,OAAO,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAA;AACtD,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,kBAAkB,EAClB,oBAAoB,EACpB,WAAW,GACZ,MAAM,SAAS,CAAA;AAChB,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAa3C;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,cAAc,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,GAAG,KAAK,EAAE,EAAE,EAAE,KAAK,EAAuB;IAClG,kFAAkF;IAClF,mFAAmF;IACnF,MAAM,MAAM,GAAG,yBAAyB,EAAE,CAAA;IAC1C,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAA;IAElC,oFAAoF;IACpF,0EAA0E;IAC1E,MAAM,WAAW,GAAG,OAAO,CAAe,GAAG,EAAE;QAC7C,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,CAAA;QACtB,MAAM,GAAG,GAAiB,EAAE,CAAA;QAC5B,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;YAC5C,IAAI,UAAU,CAAC,EAAE,KAAK,MAAM,CAAC,iBAAiB;gBAAE,SAAQ;YACxD,MAAM,eAAe,GAAG,cAAc,CAAC,UAAU,CAAC,IAAI,EAAE,aAAa,EAAE,UAAU,CAAC,EAAE,CAAC,CAAA;YACrF,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,mBAAmB,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC;gBAC5D,MAAM,GAAG,GAAG,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,CAAA;gBACrD,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,eAAe,EAAE,CAAC,CAAA;YAChE,CAAC;QACH,CAAC;QACD,OAAO,GAAG,CAAA;IACZ,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IAEZ,kEAAkE;IAClE,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC9B,QAAQ,CAAC,OAAO,GAAG,KAAK,CAAA;IACxB,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAA;IACpC,WAAW,CAAC,OAAO,GAAG,QAAQ,CAAA;IAC9B,4EAA4E;IAC5E,MAAM,WAAW,GAAG,MAAM,CAAU,KAAK,CAAC,CAAA;IAE1C,MAAM,MAAM,GAAG,SAAS,CAAC;QACvB,UAAU,EAAE,uBAAuB,EAAE;QACrC,OAAO,EAAE,WAAW,CAAC,KAAK,CAAC;QAC3B,QAAQ,EAAE,CAAC,QAAQ;QACnB,WAAW,EAAE;YACX,UAAU,EAAE,EAAE,KAAK,EAAE,gBAAgB,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE;SAC/D;QACD,QAAQ,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE;YACvB,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC,OAAO,EAA4B,CAAC,CAAA;YAC9E,MAAM,MAAM,GAAG,QAAQ,CAAC,OAAO,CAAA;YAC/B,4EAA4E;YAC5E,4EAA4E;YAC5E,MAAM,IAAI,GAAG,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC;gBAChD,CAAC,CAAC,MAAM;gBACR,CAAC,CAAC,kBAAkB,CAAC,OAAO,CAAC;oBAC3B,CAAC,CAAC,SAAS;oBACX,CAAC,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAA;YACnC,WAAW,CAAC,OAAO,GAAG,IAAI,CAAA;YAC1B,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;QAC3B,CAAC;KACF,CAAC,CAAA;IAEF,+EAA+E;IAC/E,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,MAAM;YAAE,OAAM;QACnB,IAAI,KAAK,KAAK,WAAW,CAAC,OAAO;YAAE,OAAM;QACzC,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC,OAAO,EAA4B,CAAC,CAAA;QAC9E,IAAI,oBAAoB,CAAC,OAAO,EAAE,KAAK,CAAC;YAAE,OAAM;QAChD,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAA;IACvE,CAAC,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAA;IAEnB,kFAAkF;IAClF,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,CAAA;IAChC,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAA;IAEtB,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IAExB,OAAO,CACL,eACE,SAAS,EAAE,EAAE,CACX,+EAA+E,EAC/E,wEAAwE,EACxE,QAAQ,IAAI,YAAY,CACzB,aAEA,CAAC,QAAQ,CAAC,CAAC,CAAC,CACX,KAAC,eAAe,IAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,GAAI,CACxF,CAAC,CAAC,CAAC,IAAI,EACR,KAAC,aAAa,IAAC,MAAM,EAAE,MAAM,GAAI,IAC7B,CACP,CAAA;AACH,CAAC","sourcesContent":["import { useEffect, useMemo, useRef } from 'react'\nimport { EditorContent, useEditor } from '@tiptap/react'\nimport type { RichTextDoc } from '@elytracms/core/rich-text'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { localizedLabel } from '@elytracms/studio/lib/i18n'\nimport { useOptionalCollectionView } from '../../collection-view/collection-view-context'\nimport { useOptionalAssets } from '../../assets/assets-store'\nimport { buildRichTextExtensions } from './extensions'\nimport {\n isEffectivelyEmpty,\n isEquivalentToStored,\n normalizeEditorDoc,\n richTextValueFromDoc,\n toEditorDoc,\n} from './value'\nimport { RichTextToolbar } from './toolbar'\nimport type { LinkTarget } from './link-control'\n\ninterface RichTextEditorProps {\n /** The stored `richText` value (`{ version, doc }`, legacy string, or empty). */\n value: unknown\n onChange: (value: unknown) => void\n readOnly?: boolean\n id?: string\n /** The field's embed `allow` list (component ids editors may embed). */\n allow?: string[]\n}\n\n/**\n * The CMS rich-text field editor (EC-314) — a real TipTap editor over the shared\n * `@elytracms/core/rich-text` storage, replacing the plaintext textarea stub.\n *\n * It stays *controlled without churn*: the editor is created once from the initial\n * value; the stored value is only re-pushed into ProseMirror when it changes\n * externally (locale switch, reset, compare) and is not already equivalent to what\n * is on screen. On edit, output is pruned to the lean storage convention\n * ({@link normalizeEditorDoc}) and — crucially — when an edit lands the document back\n * on its stored meaning, the *exact stored value* is re-emitted so the field never\n * shows a spurious dirty state (the same \"commit-nothing-when-equivalent\" contract\n * the canvas inline editor uses).\n */\nexport function RichTextEditor({ value, onChange, readOnly = false, id, allow }: RichTextEditorProps) {\n // Ambient context for reference-aware insertion (null outside a collection view /\n // asset backend — the editor then degrades to external links and no image insert).\n const source = useOptionalCollectionView()\n const assets = useOptionalAssets()\n\n // Every linkable document across the workspace (excluding the asset rail — you link\n // to content, not to raw files). Recomputed only when the source changes.\n const linkTargets = useMemo<LinkTarget[]>(() => {\n if (!source) return []\n const out: LinkTarget[] = []\n for (const collection of source.collections) {\n if (collection.id === source.assetCollectionId) continue\n const collectionLabel = localizedLabel(collection.form?.labelSingular, collection.id)\n for (const doc of source.referenceCandidates(collection.id)) {\n const ref = { collection: collection.id, id: doc.id }\n out.push({ ref, label: source.titleOf(ref), collectionLabel })\n }\n }\n return out\n }, [source])\n\n // Latest value/onChange, read inside the stable onUpdate closure.\n const valueRef = useRef(value)\n valueRef.current = value\n const onChangeRef = useRef(onChange)\n onChangeRef.current = onChange\n // What we last emitted upward — lets the sync effect ignore our own echoes.\n const lastEmitted = useRef<unknown>(value)\n\n const editor = useEditor({\n extensions: buildRichTextExtensions(),\n content: toEditorDoc(value),\n editable: !readOnly,\n editorProps: {\n attributes: { class: 'ec-rte-content', ...(id ? { id } : {}) },\n },\n onUpdate: ({ editor }) => {\n const current = normalizeEditorDoc(editor.getJSON() as unknown as RichTextDoc)\n const stored = valueRef.current\n // Three outcomes: unchanged → re-emit the stored value verbatim (no dirty);\n // emptied → clear the field (undefined); otherwise → the new stamped value.\n const next = isEquivalentToStored(current, stored)\n ? stored\n : isEffectivelyEmpty(current)\n ? undefined\n : richTextValueFromDoc(current)\n lastEmitted.current = next\n onChangeRef.current(next)\n },\n })\n\n // Reflect external value changes into the editor, never echoing our own emits.\n useEffect(() => {\n if (!editor) return\n if (value === lastEmitted.current) return\n const current = normalizeEditorDoc(editor.getJSON() as unknown as RichTextDoc)\n if (isEquivalentToStored(current, value)) return\n editor.commands.setContent(toEditorDoc(value), { emitUpdate: false })\n }, [editor, value])\n\n // Toggle editability without rebuilding the editor (preserves history/selection).\n useEffect(() => {\n editor?.setEditable(!readOnly)\n }, [editor, readOnly])\n\n if (!editor) return null\n\n return (\n <div\n className={cn(\n 'ec-rte overflow-hidden rounded-md border border-input bg-background shadow-sm',\n 'focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/40',\n readOnly && 'opacity-70',\n )}\n >\n {!readOnly ? (\n <RichTextToolbar editor={editor} targets={linkTargets} assets={assets} allow={allow} />\n ) : null}\n <EditorContent editor={editor} />\n </div>\n )\n}\n"]}
@@ -0,0 +1,17 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ interface EmbedControlProps {
3
+ editor: Editor;
4
+ /** The field's embed `allow` list (component ids). Empty/absent ⇒ any non-primitive. */
5
+ allow?: string[];
6
+ }
7
+ /**
8
+ * Insert a registry component into prose as a `componentEmbed` (EC-189/EC-314): the
9
+ * third place constrained composition lives. The pick-list is the field's `allow`
10
+ * list (or every non-primitive component when open), matching the `blocks` field and
11
+ * the `validateRichTextEmbeds` leash. The inserted node holds a fresh `ComponentNode`
12
+ * built from manifest prop defaults; its props are edited from the embed's own
13
+ * inspector (deferred) — insertion + round-trip land here. Hidden when nothing is
14
+ * embeddable.
15
+ */
16
+ export declare function EmbedControl({ editor, allow }: EmbedControlProps): import("react").JSX.Element | null;
17
+ export {};
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import { Blocks } from 'lucide-react';
4
+ import { COMPONENT_EMBED_NODE_TYPE } from '@elytracms/core/rich-text';
5
+ import { staticProp } from '@elytracms/core/project-graph';
6
+ import { isPrimitive } from '@elytracms/core/component-registry';
7
+ import { getStudioRegistry } from '@elytracms/studio/lib/registry';
8
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, Input, Stack, Text, } from '@elytracms/studio/ui';
9
+ import { translate } from '@elytracms/studio/lib/i18n';
10
+ import { cn } from '@elytracms/studio/lib/utils';
11
+ import { ToolbarButton } from './toolbar-button';
12
+ /**
13
+ * Insert a registry component into prose as a `componentEmbed` (EC-189/EC-314): the
14
+ * third place constrained composition lives. The pick-list is the field's `allow`
15
+ * list (or every non-primitive component when open), matching the `blocks` field and
16
+ * the `validateRichTextEmbeds` leash. The inserted node holds a fresh `ComponentNode`
17
+ * built from manifest prop defaults; its props are edited from the embed's own
18
+ * inspector (deferred) — insertion + round-trip land here. Hidden when nothing is
19
+ * embeddable.
20
+ */
21
+ export function EmbedControl({ editor, allow }) {
22
+ const [open, setOpen] = useState(false);
23
+ const [query, setQuery] = useState('');
24
+ const embeddable = useMemo(() => {
25
+ const all = getStudioRegistry().registry.list();
26
+ const permitted = allow && allow.length > 0 ? all.filter((m) => allow.includes(m.id)) : all.filter((m) => !isPrimitive(m.id));
27
+ return permitted.filter((m) => m.clientRenderable !== false);
28
+ }, [allow]);
29
+ const results = useMemo(() => {
30
+ const q = query.trim().toLowerCase();
31
+ if (!q)
32
+ return embeddable;
33
+ return embeddable.filter((m) => (m.title ?? m.id).toLowerCase().includes(q) || m.id.toLowerCase().includes(q));
34
+ }, [embeddable, query]);
35
+ if (embeddable.length === 0)
36
+ return null;
37
+ function insert(manifest) {
38
+ editor
39
+ .chain()
40
+ .focus()
41
+ .insertContent({ type: COMPONENT_EMBED_NODE_TYPE, attrs: { node: embedNode(manifest) } })
42
+ .run();
43
+ setOpen(false);
44
+ setQuery('');
45
+ }
46
+ return (_jsxs(_Fragment, { children: [_jsx(ToolbarButton, { label: translate('richText.embed.add'), onClick: () => setOpen(true), children: _jsx(Blocks, { className: "size-3.5" }) }), _jsx(Dialog, { open: open, onOpenChange: setOpen, children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: translate('richText.embed.add') }) }), _jsxs(Stack, { gap: "2", children: [_jsx(Input, { autoFocus: true, value: query, placeholder: translate('richText.link.documentSearch'), onChange: (e) => setQuery(e.target.value) }), _jsx(Stack, { gap: "none", className: "max-h-72 overflow-y-auto", children: results.length === 0 ? (_jsx(Text, { as: "span", size: "xs", tone: "muted", className: "px-1 py-2", children: translate('richText.embed.empty') })) : (results.map((manifest) => (_jsxs("button", { type: "button", onClick: () => insert(manifest), className: cn('flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left', 'hover:bg-muted focus-visible:bg-muted outline-none'), children: [_jsx(Blocks, { className: "size-4 shrink-0 text-muted-foreground" }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate text-sm font-medium", children: manifest.title ?? manifest.id }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: manifest.id })] })] }, manifest.id)))) })] })] }) })] }));
47
+ }
48
+ /** A fresh ComponentNode from a manifest's prop defaults (mirrors builder node-factory). */
49
+ function embedNode(manifest) {
50
+ const props = {};
51
+ for (const [name, spec] of Object.entries(manifest.props)) {
52
+ if (spec.default !== undefined)
53
+ props[name] = staticProp(spec.default);
54
+ }
55
+ return { id: newEmbedId(), componentId: manifest.id, props };
56
+ }
57
+ /** A unique id for an embedded node (studio runs in the browser; crypto is present). */
58
+ function newEmbedId() {
59
+ const uuid = globalThis.crypto?.randomUUID?.();
60
+ return uuid ? `embed-${uuid}` : `embed-${Math.random().toString(36).slice(2)}`;
61
+ }
62
+ //# sourceMappingURL=embed-control.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embed-control.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/embed-control.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAErC,OAAO,EAAE,yBAAyB,EAAE,MAAM,2BAA2B,CAAA;AACrE,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAA;AAE1D,OAAO,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAA;AAEhE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAA;AAClE,OAAO,EACL,MAAM,EACN,aAAa,EACb,YAAY,EACZ,WAAW,EACX,KAAK,EACL,KAAK,EACL,IAAI,GACL,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,EAAE,EAAE,MAAM,6BAA6B,CAAA;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAQhD;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAAC,EAAE,MAAM,EAAE,KAAK,EAAqB;IAC/D,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACvC,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAA;IAEtC,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,EAAE;QAC9B,MAAM,GAAG,GAAG,iBAAiB,EAAE,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAA;QAC/C,MAAM,SAAS,GACb,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;QAC7G,OAAO,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,gBAAgB,KAAK,KAAK,CAAC,CAAA;IAC9D,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;IAEX,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,EAAE;QAC3B,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;QACpC,IAAI,CAAC,CAAC;YAAE,OAAO,UAAU,CAAA;QACzB,OAAO,UAAU,CAAC,MAAM,CACtB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CACrF,CAAA;IACH,CAAC,EAAE,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC,CAAA;IAEvB,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAExC,SAAS,MAAM,CAAC,QAA2B;QACzC,MAAM;aACH,KAAK,EAAE;aACP,KAAK,EAAE;aACP,aAAa,CAAC,EAAE,IAAI,EAAE,yBAAyB,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,SAAS,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;aACxF,GAAG,EAAE,CAAA;QACR,OAAO,CAAC,KAAK,CAAC,CAAA;QACd,QAAQ,CAAC,EAAE,CAAC,CAAA;IACd,CAAC;IAED,OAAO,CACL,8BACE,KAAC,aAAa,IAAC,KAAK,EAAE,SAAS,CAAC,oBAAoB,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,YACjF,KAAC,MAAM,IAAC,SAAS,EAAC,UAAU,GAAG,GACjB,EAChB,KAAC,MAAM,IAAC,IAAI,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,YACvC,MAAC,aAAa,IAAC,SAAS,EAAC,UAAU,aACjC,KAAC,YAAY,cACX,KAAC,WAAW,cAAE,SAAS,CAAC,oBAAoB,CAAC,GAAe,GAC/C,EACf,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,aACZ,KAAC,KAAK,IACJ,SAAS,QACT,KAAK,EAAE,KAAK,EACZ,WAAW,EAAE,SAAS,CAAC,8BAA8B,CAAC,EACtD,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,GACzC,EACF,KAAC,KAAK,IAAC,GAAG,EAAC,MAAM,EAAC,SAAS,EAAC,0BAA0B,YACnD,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CACtB,KAAC,IAAI,IAAC,EAAE,EAAC,MAAM,EAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,EAAC,SAAS,EAAC,WAAW,YACzD,SAAS,CAAC,sBAAsB,CAAC,GAC7B,CACR,CAAC,CAAC,CAAC,CACF,OAAO,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CACxB,kBAEE,IAAI,EAAC,QAAQ,EACb,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,EAC/B,SAAS,EAAE,EAAE,CACX,iEAAiE,EACjE,oDAAoD,CACrD,aAED,KAAC,MAAM,IAAC,SAAS,EAAC,uCAAuC,GAAG,EAC5D,gBAAM,SAAS,EAAC,8BAA8B,aAC5C,eAAM,SAAS,EAAC,8BAA8B,YAAE,QAAQ,CAAC,KAAK,IAAI,QAAQ,CAAC,EAAE,GAAQ,EACrF,eAAM,SAAS,EAAC,wCAAwC,YAAE,QAAQ,CAAC,EAAE,GAAQ,IACxE,KAZF,QAAQ,CAAC,EAAE,CAaT,CACV,CAAC,CACH,GACK,IACF,IACM,GACT,IACR,CACJ,CAAA;AACH,CAAC;AAED,4FAA4F;AAC5F,SAAS,SAAS,CAAC,QAA2B;IAC5C,MAAM,KAAK,GAA2B,EAAE,CAAA;IACxC,KAAK,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1D,IAAI,IAAI,CAAC,OAAO,KAAK,SAAS;YAAE,KAAK,CAAC,IAAI,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,OAAoB,CAAC,CAAA;IACrF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,EAAE,CAAA;AAC9D,CAAC;AAED,wFAAwF;AACxF,SAAS,UAAU;IACjB,MAAM,IAAI,GAAG,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,EAAE,CAAA;IAC9C,OAAO,IAAI,CAAC,CAAC,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAA;AAChF,CAAC","sourcesContent":["import { useMemo, useState } from 'react'\nimport { Blocks } from 'lucide-react'\nimport type { Editor } from '@tiptap/react'\nimport { COMPONENT_EMBED_NODE_TYPE } from '@elytracms/core/rich-text'\nimport { staticProp } from '@elytracms/core/project-graph'\nimport type { ComponentNode, JsonValue } from '@elytracms/core/project-graph'\nimport { isPrimitive } from '@elytracms/core/component-registry'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport { getStudioRegistry } from '@elytracms/studio/lib/registry'\nimport {\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n Input,\n Stack,\n Text,\n} from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { ToolbarButton } from './toolbar-button'\n\ninterface EmbedControlProps {\n editor: Editor\n /** The field's embed `allow` list (component ids). Empty/absent ⇒ any non-primitive. */\n allow?: string[]\n}\n\n/**\n * Insert a registry component into prose as a `componentEmbed` (EC-189/EC-314): the\n * third place constrained composition lives. The pick-list is the field's `allow`\n * list (or every non-primitive component when open), matching the `blocks` field and\n * the `validateRichTextEmbeds` leash. The inserted node holds a fresh `ComponentNode`\n * built from manifest prop defaults; its props are edited from the embed's own\n * inspector (deferred) — insertion + round-trip land here. Hidden when nothing is\n * embeddable.\n */\nexport function EmbedControl({ editor, allow }: EmbedControlProps) {\n const [open, setOpen] = useState(false)\n const [query, setQuery] = useState('')\n\n const embeddable = useMemo(() => {\n const all = getStudioRegistry().registry.list()\n const permitted =\n allow && allow.length > 0 ? all.filter((m) => allow.includes(m.id)) : all.filter((m) => !isPrimitive(m.id))\n return permitted.filter((m) => m.clientRenderable !== false)\n }, [allow])\n\n const results = useMemo(() => {\n const q = query.trim().toLowerCase()\n if (!q) return embeddable\n return embeddable.filter(\n (m) => (m.title ?? m.id).toLowerCase().includes(q) || m.id.toLowerCase().includes(q),\n )\n }, [embeddable, query])\n\n if (embeddable.length === 0) return null\n\n function insert(manifest: ComponentManifest) {\n editor\n .chain()\n .focus()\n .insertContent({ type: COMPONENT_EMBED_NODE_TYPE, attrs: { node: embedNode(manifest) } })\n .run()\n setOpen(false)\n setQuery('')\n }\n\n return (\n <>\n <ToolbarButton label={translate('richText.embed.add')} onClick={() => setOpen(true)}>\n <Blocks className=\"size-3.5\" />\n </ToolbarButton>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>{translate('richText.embed.add')}</DialogTitle>\n </DialogHeader>\n <Stack gap=\"2\">\n <Input\n autoFocus\n value={query}\n placeholder={translate('richText.link.documentSearch')}\n onChange={(e) => setQuery(e.target.value)}\n />\n <Stack gap=\"none\" className=\"max-h-72 overflow-y-auto\">\n {results.length === 0 ? (\n <Text as=\"span\" size=\"xs\" tone=\"muted\" className=\"px-1 py-2\">\n {translate('richText.embed.empty')}\n </Text>\n ) : (\n results.map((manifest) => (\n <button\n key={manifest.id}\n type=\"button\"\n onClick={() => insert(manifest)}\n className={cn(\n 'flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left',\n 'hover:bg-muted focus-visible:bg-muted outline-none',\n )}\n >\n <Blocks className=\"size-4 shrink-0 text-muted-foreground\" />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className=\"truncate text-sm font-medium\">{manifest.title ?? manifest.id}</span>\n <span className=\"truncate text-xs text-muted-foreground\">{manifest.id}</span>\n </span>\n </button>\n ))\n )}\n </Stack>\n </Stack>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n\n/** A fresh ComponentNode from a manifest's prop defaults (mirrors builder node-factory). */\nfunction embedNode(manifest: ComponentManifest): ComponentNode {\n const props: ComponentNode['props'] = {}\n for (const [name, spec] of Object.entries(manifest.props)) {\n if (spec.default !== undefined) props[name] = staticProp(spec.default as JsonValue)\n }\n return { id: newEmbedId(), componentId: manifest.id, props }\n}\n\n/** A unique id for an embedded node (studio runs in the browser; crypto is present). */\nfunction newEmbedId(): string {\n const uuid = globalThis.crypto?.randomUUID?.()\n return uuid ? `embed-${uuid}` : `embed-${Math.random().toString(36).slice(2)}`\n}\n"]}
@@ -0,0 +1,23 @@
1
+ import type { Extensions } from '@tiptap/react';
2
+ /**
3
+ * The TipTap extension set for the CMS rich-text editor (EC-314).
4
+ *
5
+ * Deliberately matches the storage vocabulary (`@elytracms/core/rich-text` schema)
6
+ * one-for-one so every editor document round-trips through `parseRichTextValue` /
7
+ * the delivery renderer with no format change:
8
+ *
9
+ * - **StarterKit** supplies `doc/paragraph/text/heading/bulletList/orderedList/
10
+ * listItem/blockquote/codeBlock/hardBreak/horizontalRule` and the marks
11
+ * `bold/italic/code/strike/underline` — all ProseMirror-standard names identical
12
+ * to storage. Its bundled `link` is disabled and replaced by {@link RefLink} so
13
+ * internal-document links can carry a structured `ref` (EC-290). Headings allow
14
+ * all six levels so existing `h4`–`h6` content is preserved even though the
15
+ * toolbar surfaces only 1–3.
16
+ * - {@link AssetImage} / {@link ComponentEmbed} declare the two reference-carrying
17
+ * atoms (asset image, embedded registry component) so existing content of those
18
+ * kinds survives a round-trip verbatim rather than being dropped by the schema.
19
+ *
20
+ * No `allow` gating lives here — that governs *insertion* (the embed picker) and
21
+ * *validation* (`validateRichTextEmbeds`), not which nodes the schema tolerates.
22
+ */
23
+ export declare function buildRichTextExtensions(): Extensions;
@@ -0,0 +1,40 @@
1
+ import StarterKit from '@tiptap/starter-kit';
2
+ import { Placeholder } from '@tiptap/extensions';
3
+ import { translate } from '@elytracms/studio/lib/i18n';
4
+ import { RefLink } from './ref-link';
5
+ import { AssetImage } from './asset-image';
6
+ import { ComponentEmbed } from './component-embed';
7
+ /**
8
+ * The TipTap extension set for the CMS rich-text editor (EC-314).
9
+ *
10
+ * Deliberately matches the storage vocabulary (`@elytracms/core/rich-text` schema)
11
+ * one-for-one so every editor document round-trips through `parseRichTextValue` /
12
+ * the delivery renderer with no format change:
13
+ *
14
+ * - **StarterKit** supplies `doc/paragraph/text/heading/bulletList/orderedList/
15
+ * listItem/blockquote/codeBlock/hardBreak/horizontalRule` and the marks
16
+ * `bold/italic/code/strike/underline` — all ProseMirror-standard names identical
17
+ * to storage. Its bundled `link` is disabled and replaced by {@link RefLink} so
18
+ * internal-document links can carry a structured `ref` (EC-290). Headings allow
19
+ * all six levels so existing `h4`–`h6` content is preserved even though the
20
+ * toolbar surfaces only 1–3.
21
+ * - {@link AssetImage} / {@link ComponentEmbed} declare the two reference-carrying
22
+ * atoms (asset image, embedded registry component) so existing content of those
23
+ * kinds survives a round-trip verbatim rather than being dropped by the schema.
24
+ *
25
+ * No `allow` gating lives here — that governs *insertion* (the embed picker) and
26
+ * *validation* (`validateRichTextEmbeds`), not which nodes the schema tolerates.
27
+ */
28
+ export function buildRichTextExtensions() {
29
+ return [
30
+ StarterKit.configure({
31
+ link: false,
32
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
33
+ }),
34
+ RefLink.configure({ openOnClick: false, autolink: true, linkOnPaste: true }),
35
+ AssetImage,
36
+ ComponentEmbed,
37
+ Placeholder.configure({ placeholder: translate('richText.placeholder') }),
38
+ ];
39
+ }
40
+ //# sourceMappingURL=extensions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extensions.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/extensions.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,qBAAqB,CAAA;AAC5C,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAEhD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAC1C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAElD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,uBAAuB;IACrC,OAAO;QACL,UAAU,CAAC,SAAS,CAAC;YACnB,IAAI,EAAE,KAAK;YACX,OAAO,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE;SACxC,CAAC;QACF,OAAO,CAAC,SAAS,CAAC,EAAE,WAAW,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;QAC5E,UAAU;QACV,cAAc;QACd,WAAW,CAAC,SAAS,CAAC,EAAE,WAAW,EAAE,SAAS,CAAC,sBAAsB,CAAC,EAAE,CAAC;KAC1E,CAAA;AACH,CAAC","sourcesContent":["import StarterKit from '@tiptap/starter-kit'\nimport { Placeholder } from '@tiptap/extensions'\nimport type { Extensions } from '@tiptap/react'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { RefLink } from './ref-link'\nimport { AssetImage } from './asset-image'\nimport { ComponentEmbed } from './component-embed'\n\n/**\n * The TipTap extension set for the CMS rich-text editor (EC-314).\n *\n * Deliberately matches the storage vocabulary (`@elytracms/core/rich-text` schema)\n * one-for-one so every editor document round-trips through `parseRichTextValue` /\n * the delivery renderer with no format change:\n *\n * - **StarterKit** supplies `doc/paragraph/text/heading/bulletList/orderedList/\n * listItem/blockquote/codeBlock/hardBreak/horizontalRule` and the marks\n * `bold/italic/code/strike/underline` — all ProseMirror-standard names identical\n * to storage. Its bundled `link` is disabled and replaced by {@link RefLink} so\n * internal-document links can carry a structured `ref` (EC-290). Headings allow\n * all six levels so existing `h4`–`h6` content is preserved even though the\n * toolbar surfaces only 1–3.\n * - {@link AssetImage} / {@link ComponentEmbed} declare the two reference-carrying\n * atoms (asset image, embedded registry component) so existing content of those\n * kinds survives a round-trip verbatim rather than being dropped by the schema.\n *\n * No `allow` gating lives here — that governs *insertion* (the embed picker) and\n * *validation* (`validateRichTextEmbeds`), not which nodes the schema tolerates.\n */\nexport function buildRichTextExtensions(): Extensions {\n return [\n StarterKit.configure({\n link: false,\n heading: { levels: [1, 2, 3, 4, 5, 6] },\n }),\n RefLink.configure({ openOnClick: false, autolink: true, linkOnPaste: true }),\n AssetImage,\n ComponentEmbed,\n Placeholder.configure({ placeholder: translate('richText.placeholder') }),\n ]\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import type { AssetsStore } from '../../assets/assets-store';
3
+ interface ImageControlProps {
4
+ editor: Editor;
5
+ assets: AssetsStore | null;
6
+ }
7
+ /**
8
+ * Insert an editorial image (EC-314/EC-301): pick an asset from the project library
9
+ * and drop an `image` node carrying the canonical asset envelope at `attrs.ref`
10
+ * (`{ type: 'asset', id }`) — so the reference-extractor indexes it as an asset use
11
+ * and delivery resolves it to the live URL. `attrs.src` snapshots the current URL for
12
+ * the editor preview and as delivery's external fallback. Rendered only when an asset
13
+ * backend is present.
14
+ */
15
+ export declare function ImageControl({ editor, assets }: ImageControlProps): import("react").JSX.Element | null;
16
+ export {};
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { ImagePlus } from 'lucide-react';
4
+ import { assetNode } from '@elytracms/core/cms-core';
5
+ import { translate } from '@elytracms/studio/lib/i18n';
6
+ import { AssetPicker } from '../asset-picker';
7
+ import { ToolbarButton } from './toolbar-button';
8
+ /**
9
+ * Insert an editorial image (EC-314/EC-301): pick an asset from the project library
10
+ * and drop an `image` node carrying the canonical asset envelope at `attrs.ref`
11
+ * (`{ type: 'asset', id }`) — so the reference-extractor indexes it as an asset use
12
+ * and delivery resolves it to the live URL. `attrs.src` snapshots the current URL for
13
+ * the editor preview and as delivery's external fallback. Rendered only when an asset
14
+ * backend is present.
15
+ */
16
+ export function ImageControl({ editor, assets }) {
17
+ const [open, setOpen] = useState(false);
18
+ if (!assets)
19
+ return null;
20
+ function insert(assetId) {
21
+ setOpen(false);
22
+ if (!assetId)
23
+ return;
24
+ const record = assets.assets.find((a) => a.id === assetId);
25
+ editor
26
+ .chain()
27
+ .focus()
28
+ .insertContent({
29
+ type: 'image',
30
+ attrs: { src: record?.url ?? '', alt: record?.alt ?? '', ref: assetNode(assetId) },
31
+ })
32
+ .run();
33
+ }
34
+ return (_jsxs(_Fragment, { children: [_jsx(ToolbarButton, { label: translate('richText.image.add'), onClick: () => setOpen(true), children: _jsx(ImagePlus, { className: "size-3.5" }) }), _jsx(AssetPicker, { open: open, onOpenChange: setOpen, value: null, onChange: insert, accept: "image/*" })] }));
35
+ }
36
+ //# sourceMappingURL=image-control.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-control.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/image-control.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAExC,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAOhD;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,EAAE,MAAM,EAAE,MAAM,EAAqB;IAChE,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAEvC,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IAExB,SAAS,MAAM,CAAC,OAAsB;QACpC,OAAO,CAAC,KAAK,CAAC,CAAA;QACd,IAAI,CAAC,OAAO;YAAE,OAAM;QACpB,MAAM,MAAM,GAAG,MAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,OAAO,CAAC,CAAA;QAC3D,MAAM;aACH,KAAK,EAAE;aACP,KAAK,EAAE;aACP,aAAa,CAAC;YACb,IAAI,EAAE,OAAO;YACb,KAAK,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,EAAE,GAAG,EAAE,SAAS,CAAC,OAAO,CAAC,EAAE;SACnF,CAAC;aACD,GAAG,EAAE,CAAA;IACV,CAAC;IAED,OAAO,CACL,8BACE,KAAC,aAAa,IAAC,KAAK,EAAE,SAAS,CAAC,oBAAoB,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,YACjF,KAAC,SAAS,IAAC,SAAS,EAAC,UAAU,GAAG,GACpB,EAChB,KAAC,WAAW,IAAC,IAAI,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAC,SAAS,GAAG,IACjG,CACJ,CAAA;AACH,CAAC","sourcesContent":["import { useState } from 'react'\nimport { ImagePlus } from 'lucide-react'\nimport type { Editor } from '@tiptap/react'\nimport { assetNode } from '@elytracms/core/cms-core'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport type { AssetsStore } from '../../assets/assets-store'\nimport { AssetPicker } from '../asset-picker'\nimport { ToolbarButton } from './toolbar-button'\n\ninterface ImageControlProps {\n editor: Editor\n assets: AssetsStore | null\n}\n\n/**\n * Insert an editorial image (EC-314/EC-301): pick an asset from the project library\n * and drop an `image` node carrying the canonical asset envelope at `attrs.ref`\n * (`{ type: 'asset', id }`) — so the reference-extractor indexes it as an asset use\n * and delivery resolves it to the live URL. `attrs.src` snapshots the current URL for\n * the editor preview and as delivery's external fallback. Rendered only when an asset\n * backend is present.\n */\nexport function ImageControl({ editor, assets }: ImageControlProps) {\n const [open, setOpen] = useState(false)\n\n if (!assets) return null\n\n function insert(assetId: string | null) {\n setOpen(false)\n if (!assetId) return\n const record = assets!.assets.find((a) => a.id === assetId)\n editor\n .chain()\n .focus()\n .insertContent({\n type: 'image',\n attrs: { src: record?.url ?? '', alt: record?.alt ?? '', ref: assetNode(assetId) },\n })\n .run()\n }\n\n return (\n <>\n <ToolbarButton label={translate('richText.image.add')} onClick={() => setOpen(true)}>\n <ImagePlus className=\"size-3.5\" />\n </ToolbarButton>\n <AssetPicker open={open} onOpenChange={setOpen} value={null} onChange={insert} accept=\"image/*\" />\n </>\n )\n}\n"]}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The CMS rich-text field editor (EC-314) — a TipTap editor over the shared
3
+ * `@elytracms/core/rich-text` storage. Public surface is the {@link RichTextEditor}
4
+ * component wired into the `richText` field control; everything else (extensions,
5
+ * toolbar, value bridging) is an implementation detail.
6
+ */
7
+ export { RichTextEditor } from './editor';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The CMS rich-text field editor (EC-314) — a TipTap editor over the shared
3
+ * `@elytracms/core/rich-text` storage. Public surface is the {@link RichTextEditor}
4
+ * component wired into the `richText` field control; everything else (extensions,
5
+ * toolbar, value bridging) is an implementation detail.
6
+ */
7
+ export { RichTextEditor } from './editor';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA","sourcesContent":["/**\n * The CMS rich-text field editor (EC-314) — a TipTap editor over the shared\n * `@elytracms/core/rich-text` storage. Public surface is the {@link RichTextEditor}\n * component wired into the `richText` field control; everything else (extensions,\n * toolbar, value bridging) is an implementation detail.\n */\nexport { RichTextEditor } from './editor'\n"]}
@@ -0,0 +1,22 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import type { DocumentRef } from '@elytracms/core/cms-core';
3
+ /** A pickable internal-link destination: a document plus its display labels. */
4
+ export interface LinkTarget {
5
+ ref: DocumentRef;
6
+ label: string;
7
+ collectionLabel: string;
8
+ }
9
+ interface LinkControlProps {
10
+ editor: Editor;
11
+ /** Documents linkable across the workspace (empty ⇒ external-only). */
12
+ targets: LinkTarget[];
13
+ }
14
+ /**
15
+ * The rich-text link control (EC-314): set/clear a link on the selection, either as
16
+ * an external URL (a literal `href`) or an internal document — which writes a
17
+ * structured `attrs.ref` (`referenceNode`, EC-290) so the link stays indexed like a
18
+ * relation and resolves to a live path in delivery. Applying an empty URL clears the
19
+ * link, so the one control both sets and removes.
20
+ */
21
+ export declare function LinkControl({ editor, targets }: LinkControlProps): import("react").JSX.Element;
22
+ export {};
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import { FileText, Link2, Trash2 } from 'lucide-react';
4
+ import { referenceNode } from '@elytracms/core/cms-core';
5
+ import { Button, Input, Popover, PopoverContent, PopoverTrigger, Row, Separator, Stack, Text, } from '@elytracms/studio/ui';
6
+ import { cn } from '@elytracms/studio/lib/utils';
7
+ import { translate } from '@elytracms/studio/lib/i18n';
8
+ const MAX_RESULTS = 40;
9
+ /**
10
+ * The rich-text link control (EC-314): set/clear a link on the selection, either as
11
+ * an external URL (a literal `href`) or an internal document — which writes a
12
+ * structured `attrs.ref` (`referenceNode`, EC-290) so the link stays indexed like a
13
+ * relation and resolves to a live path in delivery. Applying an empty URL clears the
14
+ * link, so the one control both sets and removes.
15
+ */
16
+ export function LinkControl({ editor, targets }) {
17
+ const [open, setOpen] = useState(false);
18
+ const [href, setHref] = useState('');
19
+ const [query, setQuery] = useState('');
20
+ const isLink = editor.isActive('link');
21
+ const results = useMemo(() => {
22
+ const q = query.trim().toLowerCase();
23
+ const matched = q
24
+ ? targets.filter((t) => t.label.toLowerCase().includes(q) || t.collectionLabel.toLowerCase().includes(q))
25
+ : targets;
26
+ return matched.slice(0, MAX_RESULTS);
27
+ }, [targets, query]);
28
+ function onOpenChange(next) {
29
+ if (next) {
30
+ const current = editor.getAttributes('link').href;
31
+ setHref(typeof current === 'string' ? current : '');
32
+ setQuery('');
33
+ }
34
+ setOpen(next);
35
+ }
36
+ function applyExternal() {
37
+ const url = href.trim();
38
+ const chain = editor.chain().focus().extendMarkRange('link');
39
+ if (url)
40
+ chain.setLink({ href: url }).run();
41
+ else
42
+ chain.unsetLink().run();
43
+ setOpen(false);
44
+ }
45
+ function applyDocument(target) {
46
+ editor
47
+ .chain()
48
+ .focus()
49
+ .extendMarkRange('link')
50
+ .setMark('link', { href: null, ref: referenceNode(target.ref) })
51
+ .run();
52
+ setOpen(false);
53
+ }
54
+ function remove() {
55
+ editor.chain().focus().extendMarkRange('link').unsetLink().run();
56
+ setOpen(false);
57
+ }
58
+ return (_jsxs(Popover, { open: open, onOpenChange: onOpenChange, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: isLink ? 'secondary' : 'ghost', size: "icon-sm", "aria-label": translate('richText.link.add'), "aria-pressed": isLink, title: translate('richText.link.add'), onMouseDown: (e) => e.preventDefault(), children: _jsx(Link2, { className: "size-3.5" }) }) }), _jsx(PopoverContent, { align: "start", className: "w-80", children: _jsxs(Stack, { gap: "2", children: [_jsx(Text, { as: "span", size: "xs", tone: "muted", className: "uppercase tracking-wide", children: translate('richText.link.external') }), _jsxs(Row, { gap: "2", align: "center", children: [_jsx(Input, { autoFocus: true, value: href, placeholder: translate('richText.link.externalPlaceholder'), onChange: (e) => setHref(e.target.value), onKeyDown: (e) => {
59
+ if (e.key === 'Enter') {
60
+ e.preventDefault();
61
+ applyExternal();
62
+ }
63
+ } }), _jsx(Button, { type: "button", size: "sm", onClick: applyExternal, children: translate('richText.link.apply') })] }), targets.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Separator, { className: "my-0.5" }), _jsx(Text, { as: "span", size: "xs", tone: "muted", className: "uppercase tracking-wide", children: translate('richText.link.document') }), _jsx(Input, { value: query, placeholder: translate('richText.link.documentSearch'), onChange: (e) => setQuery(e.target.value) }), _jsx(Stack, { gap: "none", className: "max-h-52 overflow-y-auto", children: results.length === 0 ? (_jsx(Text, { as: "span", size: "xs", tone: "muted", className: "px-1 py-2", children: translate('richText.link.documentEmpty') })) : (results.map((target) => (_jsxs("button", { type: "button", onMouseDown: (e) => e.preventDefault(), onClick: () => applyDocument(target), className: cn('flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm', 'hover:bg-muted focus-visible:bg-muted outline-none'), children: [_jsx(FileText, { className: "size-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: target.label }), _jsx("span", { className: "shrink-0 text-xs text-muted-foreground", children: target.collectionLabel })] }, `${target.ref.collection}:${target.ref.id}`)))) })] })) : null, isLink ? (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "self-start", onClick: remove, children: [_jsx(Trash2, { className: "size-3.5" }), " ", translate('richText.link.remove')] })) : null] }) })] }));
64
+ }
65
+ //# sourceMappingURL=link-control.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-control.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/link-control.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useMemo, useState } from 'react'\nimport { FileText, Link2, Trash2 } from 'lucide-react'\nimport type { Editor } from '@tiptap/react'\nimport { referenceNode } from '@elytracms/core/cms-core'\nimport type { DocumentRef } from '@elytracms/core/cms-core'\nimport {\n Button,\n Input,\n Popover,\n PopoverContent,\n PopoverTrigger,\n Row,\n Separator,\n Stack,\n Text,\n} from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/** A pickable internal-link destination: a document plus its display labels. */\nexport interface LinkTarget {\n ref: DocumentRef\n label: string\n collectionLabel: string\n}\n\ninterface LinkControlProps {\n editor: Editor\n /** Documents linkable across the workspace (empty ⇒ external-only). */\n targets: LinkTarget[]\n}\n\nconst MAX_RESULTS = 40\n\n/**\n * The rich-text link control (EC-314): set/clear a link on the selection, either as\n * an external URL (a literal `href`) or an internal document — which writes a\n * structured `attrs.ref` (`referenceNode`, EC-290) so the link stays indexed like a\n * relation and resolves to a live path in delivery. Applying an empty URL clears the\n * link, so the one control both sets and removes.\n */\nexport function LinkControl({ editor, targets }: LinkControlProps) {\n const [open, setOpen] = useState(false)\n const [href, setHref] = useState('')\n const [query, setQuery] = useState('')\n const isLink = editor.isActive('link')\n\n const results = useMemo(() => {\n const q = query.trim().toLowerCase()\n const matched = q\n ? targets.filter((t) => t.label.toLowerCase().includes(q) || t.collectionLabel.toLowerCase().includes(q))\n : targets\n return matched.slice(0, MAX_RESULTS)\n }, [targets, query])\n\n function onOpenChange(next: boolean) {\n if (next) {\n const current = editor.getAttributes('link').href\n setHref(typeof current === 'string' ? current : '')\n setQuery('')\n }\n setOpen(next)\n }\n\n function applyExternal() {\n const url = href.trim()\n const chain = editor.chain().focus().extendMarkRange('link')\n if (url) chain.setLink({ href: url }).run()\n else chain.unsetLink().run()\n setOpen(false)\n }\n\n function applyDocument(target: LinkTarget) {\n editor\n .chain()\n .focus()\n .extendMarkRange('link')\n .setMark('link', { href: null, ref: referenceNode(target.ref) })\n .run()\n setOpen(false)\n }\n\n function remove() {\n editor.chain().focus().extendMarkRange('link').unsetLink().run()\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={onOpenChange}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant={isLink ? 'secondary' : 'ghost'}\n size=\"icon-sm\"\n aria-label={translate('richText.link.add')}\n aria-pressed={isLink}\n title={translate('richText.link.add')}\n onMouseDown={(e) => e.preventDefault()}\n >\n <Link2 className=\"size-3.5\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"w-80\">\n <Stack gap=\"2\">\n <Text as=\"span\" size=\"xs\" tone=\"muted\" className=\"uppercase tracking-wide\">\n {translate('richText.link.external')}\n </Text>\n <Row gap=\"2\" align=\"center\">\n <Input\n autoFocus\n value={href}\n placeholder={translate('richText.link.externalPlaceholder')}\n onChange={(e) => setHref(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n applyExternal()\n }\n }}\n />\n <Button type=\"button\" size=\"sm\" onClick={applyExternal}>\n {translate('richText.link.apply')}\n </Button>\n </Row>\n\n {targets.length > 0 ? (\n <>\n <Separator className=\"my-0.5\" />\n <Text as=\"span\" size=\"xs\" tone=\"muted\" className=\"uppercase tracking-wide\">\n {translate('richText.link.document')}\n </Text>\n <Input\n value={query}\n placeholder={translate('richText.link.documentSearch')}\n onChange={(e) => setQuery(e.target.value)}\n />\n <Stack gap=\"none\" className=\"max-h-52 overflow-y-auto\">\n {results.length === 0 ? (\n <Text as=\"span\" size=\"xs\" tone=\"muted\" className=\"px-1 py-2\">\n {translate('richText.link.documentEmpty')}\n </Text>\n ) : (\n results.map((target) => (\n <button\n key={`${target.ref.collection}:${target.ref.id}`}\n type=\"button\"\n onMouseDown={(e) => e.preventDefault()}\n onClick={() => applyDocument(target)}\n className={cn(\n 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm',\n 'hover:bg-muted focus-visible:bg-muted outline-none',\n )}\n >\n <FileText className=\"size-3.5 shrink-0 text-muted-foreground\" />\n <span className=\"min-w-0 flex-1 truncate\">{target.label}</span>\n <span className=\"shrink-0 text-xs text-muted-foreground\">{target.collectionLabel}</span>\n </button>\n ))\n )}\n </Stack>\n </>\n ) : null}\n\n {isLink ? (\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" className=\"self-start\" onClick={remove}>\n <Trash2 className=\"size-3.5\" /> {translate('richText.link.remove')}\n </Button>\n ) : null}\n </Stack>\n </PopoverContent>\n </Popover>\n )\n}\n"]}