@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.
- package/dist/components/cms/field-control.js +2 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/rich-text/asset-image.d.ts +12 -0
- package/dist/components/cms/rich-text/asset-image.js +24 -0
- package/dist/components/cms/rich-text/asset-image.js.map +1 -0
- package/dist/components/cms/rich-text/component-embed.d.ts +15 -0
- package/dist/components/cms/rich-text/component-embed.js +54 -0
- package/dist/components/cms/rich-text/component-embed.js.map +1 -0
- package/dist/components/cms/rich-text/editor.d.ts +24 -0
- package/dist/components/cms/rich-text/editor.js +93 -0
- package/dist/components/cms/rich-text/editor.js.map +1 -0
- package/dist/components/cms/rich-text/embed-control.d.ts +17 -0
- package/dist/components/cms/rich-text/embed-control.js +62 -0
- package/dist/components/cms/rich-text/embed-control.js.map +1 -0
- package/dist/components/cms/rich-text/extensions.d.ts +23 -0
- package/dist/components/cms/rich-text/extensions.js +40 -0
- package/dist/components/cms/rich-text/extensions.js.map +1 -0
- package/dist/components/cms/rich-text/image-control.d.ts +16 -0
- package/dist/components/cms/rich-text/image-control.js +36 -0
- package/dist/components/cms/rich-text/image-control.js.map +1 -0
- package/dist/components/cms/rich-text/index.d.ts +7 -0
- package/dist/components/cms/rich-text/index.js +8 -0
- package/dist/components/cms/rich-text/index.js.map +1 -0
- package/dist/components/cms/rich-text/link-control.d.ts +22 -0
- package/dist/components/cms/rich-text/link-control.js +65 -0
- package/dist/components/cms/rich-text/link-control.js.map +1 -0
- package/dist/components/cms/rich-text/ref-link.d.ts +14 -0
- package/dist/components/cms/rich-text/ref-link.js +26 -0
- package/dist/components/cms/rich-text/ref-link.js.map +1 -0
- package/dist/components/cms/rich-text/toolbar-button.d.ts +15 -0
- package/dist/components/cms/rich-text/toolbar-button.js +11 -0
- package/dist/components/cms/rich-text/toolbar-button.js.map +1 -0
- package/dist/components/cms/rich-text/toolbar.d.ts +20 -0
- package/dist/components/cms/rich-text/toolbar.js +40 -0
- package/dist/components/cms/rich-text/toolbar.js.map +1 -0
- package/dist/components/cms/rich-text/value.d.ts +33 -0
- package/dist/components/cms/rich-text/value.js +114 -0
- package/dist/components/cms/rich-text/value.js.map +1 -0
- package/dist/lib/i18n/messages.en.js +33 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/package.json +8 -2
- 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":"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","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":"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","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"]}
|