@elytracms/studio 0.0.36 → 0.0.38

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 (51) 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 +27 -0
  10. package/dist/components/cms/rich-text/editor.js +111 -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/embed-picker.d.ts +17 -0
  16. package/dist/components/cms/rich-text/embed-picker.js +34 -0
  17. package/dist/components/cms/rich-text/embed-picker.js.map +1 -0
  18. package/dist/components/cms/rich-text/extensions.d.ts +29 -0
  19. package/dist/components/cms/rich-text/extensions.js +45 -0
  20. package/dist/components/cms/rich-text/extensions.js.map +1 -0
  21. package/dist/components/cms/rich-text/image-control.d.ts +16 -0
  22. package/dist/components/cms/rich-text/image-control.js +36 -0
  23. package/dist/components/cms/rich-text/image-control.js.map +1 -0
  24. package/dist/components/cms/rich-text/index.d.ts +7 -0
  25. package/dist/components/cms/rich-text/index.js +8 -0
  26. package/dist/components/cms/rich-text/index.js.map +1 -0
  27. package/dist/components/cms/rich-text/insert.d.ts +21 -0
  28. package/dist/components/cms/rich-text/insert.js +55 -0
  29. package/dist/components/cms/rich-text/insert.js.map +1 -0
  30. package/dist/components/cms/rich-text/link-control.d.ts +22 -0
  31. package/dist/components/cms/rich-text/link-control.js +65 -0
  32. package/dist/components/cms/rich-text/link-control.js.map +1 -0
  33. package/dist/components/cms/rich-text/ref-link.d.ts +14 -0
  34. package/dist/components/cms/rich-text/ref-link.js +26 -0
  35. package/dist/components/cms/rich-text/ref-link.js.map +1 -0
  36. package/dist/components/cms/rich-text/slash-command.d.ts +27 -0
  37. package/dist/components/cms/rich-text/slash-command.js +158 -0
  38. package/dist/components/cms/rich-text/slash-command.js.map +1 -0
  39. package/dist/components/cms/rich-text/toolbar-button.d.ts +15 -0
  40. package/dist/components/cms/rich-text/toolbar-button.js +11 -0
  41. package/dist/components/cms/rich-text/toolbar-button.js.map +1 -0
  42. package/dist/components/cms/rich-text/toolbar.d.ts +19 -0
  43. package/dist/components/cms/rich-text/toolbar.js +38 -0
  44. package/dist/components/cms/rich-text/toolbar.js.map +1 -0
  45. package/dist/components/cms/rich-text/value.d.ts +33 -0
  46. package/dist/components/cms/rich-text/value.js +114 -0
  47. package/dist/components/cms/rich-text/value.js.map +1 -0
  48. package/dist/lib/i18n/messages.en.js +35 -0
  49. package/dist/lib/i18n/messages.en.js.map +1 -1
  50. package/package.json +9 -2
  51. package/src/styles/studio.css +122 -0
@@ -0,0 +1,21 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import type { ComponentNode } from '@elytracms/core/project-graph';
3
+ import type { ComponentManifest } from '@elytracms/core/component-registry';
4
+ import type { AssetsStore } from '../../assets/assets-store';
5
+ /**
6
+ * Reference-carrying node insertions shared by the toolbar controls and the slash
7
+ * menu (EC-314). Centralised so both entry points produce byte-identical envelopes
8
+ * (the shapes `reference-extraction` indexes).
9
+ */
10
+ /** Insert an `image` node for a project asset (canonical `attrs.ref`, snapshot `src`). */
11
+ export declare function insertAssetImage(editor: Editor, assetId: string, assets: AssetsStore | null): void;
12
+ /** Insert a `componentEmbed` wrapping a fresh ComponentNode built from manifest defaults. */
13
+ export declare function insertComponentEmbed(editor: Editor, manifest: ComponentManifest): void;
14
+ /** A fresh ComponentNode from a manifest's prop defaults (mirrors builder node-factory). */
15
+ export declare function embedNodeFromManifest(manifest: ComponentManifest): ComponentNode;
16
+ /**
17
+ * The components embeddable in a rich-text field: the field's `allow` list, or every
18
+ * non-primitive component when open — matching the `blocks` field and the
19
+ * `validateRichTextEmbeds` leash.
20
+ */
21
+ export declare function listEmbeddable(allow: string[] | undefined, registered: ComponentManifest[]): ComponentManifest[];
@@ -0,0 +1,55 @@
1
+ import { assetNode } from '@elytracms/core/cms-core';
2
+ import { COMPONENT_EMBED_NODE_TYPE } from '@elytracms/core/rich-text';
3
+ import { staticProp } from '@elytracms/core/project-graph';
4
+ import { isPrimitive } from '@elytracms/core/component-registry';
5
+ /**
6
+ * Reference-carrying node insertions shared by the toolbar controls and the slash
7
+ * menu (EC-314). Centralised so both entry points produce byte-identical envelopes
8
+ * (the shapes `reference-extraction` indexes).
9
+ */
10
+ /** Insert an `image` node for a project asset (canonical `attrs.ref`, snapshot `src`). */
11
+ export function insertAssetImage(editor, assetId, assets) {
12
+ const record = assets?.assets.find((a) => a.id === assetId);
13
+ editor
14
+ .chain()
15
+ .focus()
16
+ .insertContent({
17
+ type: 'image',
18
+ attrs: { src: record?.url ?? '', alt: record?.alt ?? '', ref: assetNode(assetId) },
19
+ })
20
+ .run();
21
+ }
22
+ /** Insert a `componentEmbed` wrapping a fresh ComponentNode built from manifest defaults. */
23
+ export function insertComponentEmbed(editor, manifest) {
24
+ editor
25
+ .chain()
26
+ .focus()
27
+ .insertContent({ type: COMPONENT_EMBED_NODE_TYPE, attrs: { node: embedNodeFromManifest(manifest) } })
28
+ .run();
29
+ }
30
+ /** A fresh ComponentNode from a manifest's prop defaults (mirrors builder node-factory). */
31
+ export function embedNodeFromManifest(manifest) {
32
+ const props = {};
33
+ for (const [name, spec] of Object.entries(manifest.props)) {
34
+ if (spec.default !== undefined)
35
+ props[name] = staticProp(spec.default);
36
+ }
37
+ return { id: newEmbedId(), componentId: manifest.id, props };
38
+ }
39
+ /**
40
+ * The components embeddable in a rich-text field: the field's `allow` list, or every
41
+ * non-primitive component when open — matching the `blocks` field and the
42
+ * `validateRichTextEmbeds` leash.
43
+ */
44
+ export function listEmbeddable(allow, registered) {
45
+ const permitted = allow && allow.length > 0
46
+ ? registered.filter((m) => allow.includes(m.id))
47
+ : registered.filter((m) => !isPrimitive(m.id));
48
+ return permitted.filter((m) => m.clientRenderable !== false);
49
+ }
50
+ /** A unique id for an embedded node (studio runs in the browser; crypto is present). */
51
+ function newEmbedId() {
52
+ const uuid = globalThis.crypto?.randomUUID?.();
53
+ return uuid ? `embed-${uuid}` : `embed-${Math.random().toString(36).slice(2)}`;
54
+ }
55
+ //# sourceMappingURL=insert.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"insert.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/insert.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAA;AACpD,OAAO,EAAE,yBAAyB,EAAE,MAAM,2BAA2B,CAAA;AACrE,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAA;AAE1D,OAAO,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAA;AAIhE;;;;GAIG;AAEH,0FAA0F;AAC1F,MAAM,UAAU,gBAAgB,CAAC,MAAc,EAAE,OAAe,EAAE,MAA0B;IAC1F,MAAM,MAAM,GAAG,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,OAAO,CAAC,CAAA;IAC3D,MAAM;SACH,KAAK,EAAE;SACP,KAAK,EAAE;SACP,aAAa,CAAC;QACb,IAAI,EAAE,OAAO;QACb,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;KACnF,CAAC;SACD,GAAG,EAAE,CAAA;AACV,CAAC;AAED,6FAA6F;AAC7F,MAAM,UAAU,oBAAoB,CAAC,MAAc,EAAE,QAA2B;IAC9E,MAAM;SACH,KAAK,EAAE;SACP,KAAK,EAAE;SACP,aAAa,CAAC,EAAE,IAAI,EAAE,yBAAyB,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,qBAAqB,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;SACpG,GAAG,EAAE,CAAA;AACV,CAAC;AAED,4FAA4F;AAC5F,MAAM,UAAU,qBAAqB,CAAC,QAA2B;IAC/D,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;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,KAA2B,EAC3B,UAA+B;IAE/B,MAAM,SAAS,GACb,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;QACvB,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAChD,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IAClD,OAAO,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,gBAAgB,KAAK,KAAK,CAAC,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 type { Editor } from '@tiptap/react'\nimport { assetNode } from '@elytracms/core/cms-core'\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 type { AssetsStore } from '../../assets/assets-store'\n\n/**\n * Reference-carrying node insertions shared by the toolbar controls and the slash\n * menu (EC-314). Centralised so both entry points produce byte-identical envelopes\n * (the shapes `reference-extraction` indexes).\n */\n\n/** Insert an `image` node for a project asset (canonical `attrs.ref`, snapshot `src`). */\nexport function insertAssetImage(editor: Editor, assetId: string, assets: AssetsStore | null): void {\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/** Insert a `componentEmbed` wrapping a fresh ComponentNode built from manifest defaults. */\nexport function insertComponentEmbed(editor: Editor, manifest: ComponentManifest): void {\n editor\n .chain()\n .focus()\n .insertContent({ type: COMPONENT_EMBED_NODE_TYPE, attrs: { node: embedNodeFromManifest(manifest) } })\n .run()\n}\n\n/** A fresh ComponentNode from a manifest's prop defaults (mirrors builder node-factory). */\nexport function embedNodeFromManifest(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/**\n * The components embeddable in a rich-text field: the field's `allow` list, or every\n * non-primitive component when open — matching the `blocks` field and the\n * `validateRichTextEmbeds` leash.\n */\nexport function listEmbeddable(\n allow: string[] | undefined,\n registered: ComponentManifest[],\n): ComponentManifest[] {\n const permitted =\n allow && allow.length > 0\n ? registered.filter((m) => allow.includes(m.id))\n : registered.filter((m) => !isPrimitive(m.id))\n return permitted.filter((m) => m.clientRenderable !== false)\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,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"]}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The CMS link mark (EC-314), extending TipTap's Link with a structured `ref`
3
+ * attribute (EC-290/291).
4
+ *
5
+ * A link either points *outward* (a literal `href`, handled by the base extension)
6
+ * or *inward* to a project document, in which case it carries a self-typed
7
+ * reference envelope at `attrs.ref` (`{ type: 'reference', collection, id }`). The
8
+ * reference-extractor keys on that envelope — node/mark *type* is irrelevant to it —
9
+ * so an internal link stays indexed identically to a relation field, and the
10
+ * delivery renderer resolves `ref` to a live path (falling back to `href` only when
11
+ * no `ref` is present). `rendered: false` keeps `ref` out of the editor's HTML (it
12
+ * is storage/JSON only) while still round-tripping through `getJSON()`.
13
+ */
14
+ export declare const RefLink: import("@tiptap/core").Mark<import("@tiptap/extension-link").LinkOptions, any>;
@@ -0,0 +1,26 @@
1
+ import Link from '@tiptap/extension-link';
2
+ /**
3
+ * The CMS link mark (EC-314), extending TipTap's Link with a structured `ref`
4
+ * attribute (EC-290/291).
5
+ *
6
+ * A link either points *outward* (a literal `href`, handled by the base extension)
7
+ * or *inward* to a project document, in which case it carries a self-typed
8
+ * reference envelope at `attrs.ref` (`{ type: 'reference', collection, id }`). The
9
+ * reference-extractor keys on that envelope — node/mark *type* is irrelevant to it —
10
+ * so an internal link stays indexed identically to a relation field, and the
11
+ * delivery renderer resolves `ref` to a live path (falling back to `href` only when
12
+ * no `ref` is present). `rendered: false` keeps `ref` out of the editor's HTML (it
13
+ * is storage/JSON only) while still round-tripping through `getJSON()`.
14
+ */
15
+ export const RefLink = Link.extend({
16
+ addAttributes() {
17
+ return {
18
+ ...this.parent?.(),
19
+ ref: {
20
+ default: null,
21
+ rendered: false,
22
+ },
23
+ };
24
+ },
25
+ });
26
+ //# sourceMappingURL=ref-link.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ref-link.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/ref-link.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,wBAAwB,CAAA;AAEzC;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC;IACjC,aAAa;QACX,OAAO;YACL,GAAG,IAAI,CAAC,MAAM,EAAE,EAAE;YAClB,GAAG,EAAE;gBACH,OAAO,EAAE,IAAI;gBACb,QAAQ,EAAE,KAAK;aAChB;SACF,CAAA;IACH,CAAC;CACF,CAAC,CAAA","sourcesContent":["import Link from '@tiptap/extension-link'\n\n/**\n * The CMS link mark (EC-314), extending TipTap's Link with a structured `ref`\n * attribute (EC-290/291).\n *\n * A link either points *outward* (a literal `href`, handled by the base extension)\n * or *inward* to a project document, in which case it carries a self-typed\n * reference envelope at `attrs.ref` (`{ type: 'reference', collection, id }`). The\n * reference-extractor keys on that envelope — node/mark *type* is irrelevant to it —\n * so an internal link stays indexed identically to a relation field, and the\n * delivery renderer resolves `ref` to a live path (falling back to `href` only when\n * no `ref` is present). `rendered: false` keeps `ref` out of the editor's HTML (it\n * is storage/JSON only) while still round-tripping through `getJSON()`.\n */\nexport const RefLink = Link.extend({\n addAttributes() {\n return {\n ...this.parent?.(),\n ref: {\n default: null,\n rendered: false,\n },\n }\n },\n})\n"]}
@@ -0,0 +1,27 @@
1
+ import { Extension } from '@tiptap/react';
2
+ import type { Editor, Range } from '@tiptap/react';
3
+ import type { LucideIcon } from 'lucide-react';
4
+ /**
5
+ * Notion-style slash menu (EC-314): typing `/` at the caret opens a filterable
6
+ * command palette that inserts block-level content — headings, lists, quote, code,
7
+ * divider — and, when available, an image or a component embed (delegated to the
8
+ * editor's shared pickers via {@link SlashCommandOptions}). Built on TipTap's
9
+ * `Suggestion` utility; the menu is a React overlay positioned at the caret.
10
+ */
11
+ export interface SlashCommandOptions {
12
+ /** Open the asset picker (omitted ⇒ no `/image` item). */
13
+ onImage?: () => void;
14
+ /** Open the component-embed picker (omitted ⇒ no `/embed` item). */
15
+ onEmbed?: () => void;
16
+ }
17
+ export interface SlashItem {
18
+ key: string;
19
+ title: string;
20
+ keywords: string;
21
+ icon: LucideIcon;
22
+ run: (editor: Editor, range: Range) => void;
23
+ }
24
+ /** The command list, gated by which media actions the editor provided. */
25
+ export declare function buildSlashItems(options: SlashCommandOptions): SlashItem[];
26
+ /** Build the slash-command extension bound to the editor's shared media pickers. */
27
+ export declare function createSlashCommand(options: SlashCommandOptions): Extension<SlashCommandOptions, any>;
@@ -0,0 +1,158 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
3
+ import { Extension, ReactRenderer } from '@tiptap/react';
4
+ import Suggestion from '@tiptap/suggestion';
5
+ import { Blocks, Heading1, Heading2, Heading3, ImagePlus, List, ListOrdered, Minus, Pilcrow, Quote, SquareCode, } from 'lucide-react';
6
+ import { translate } from '@elytracms/studio/lib/i18n';
7
+ import { cn } from '@elytracms/studio/lib/utils';
8
+ /** The command list, gated by which media actions the editor provided. */
9
+ export function buildSlashItems(options) {
10
+ const block = (run) => (editor, range) => run(editor.chain().focus().deleteRange(range)).run();
11
+ const items = [
12
+ { key: 'text', title: translate('richText.slash.text'), keywords: 'paragraph text plain', icon: Pilcrow, run: block((c) => c.setParagraph()) },
13
+ { key: 'h1', title: translate('richText.heading', { level: 1 }), keywords: 'heading title h1', icon: Heading1, run: block((c) => c.toggleHeading({ level: 1 })) },
14
+ { key: 'h2', title: translate('richText.heading', { level: 2 }), keywords: 'heading subtitle h2', icon: Heading2, run: block((c) => c.toggleHeading({ level: 2 })) },
15
+ { key: 'h3', title: translate('richText.heading', { level: 3 }), keywords: 'heading h3', icon: Heading3, run: block((c) => c.toggleHeading({ level: 3 })) },
16
+ { key: 'bulletList', title: translate('richText.bulletList'), keywords: 'bullet unordered list ul', icon: List, run: block((c) => c.toggleBulletList()) },
17
+ { key: 'orderedList', title: translate('richText.orderedList'), keywords: 'ordered numbered list ol', icon: ListOrdered, run: block((c) => c.toggleOrderedList()) },
18
+ { key: 'blockquote', title: translate('richText.blockquote'), keywords: 'quote blockquote', icon: Quote, run: block((c) => c.toggleBlockquote()) },
19
+ { key: 'codeBlock', title: translate('richText.codeBlock'), keywords: 'code block pre', icon: SquareCode, run: block((c) => c.toggleCodeBlock()) },
20
+ { key: 'divider', title: translate('richText.horizontalRule'), keywords: 'divider rule hr separator', icon: Minus, run: block((c) => c.setHorizontalRule()) },
21
+ ];
22
+ if (options.onImage) {
23
+ items.push({
24
+ key: 'image',
25
+ title: translate('richText.image.add'),
26
+ keywords: 'image picture photo media',
27
+ icon: ImagePlus,
28
+ run: (editor, range) => {
29
+ editor.chain().focus().deleteRange(range).run();
30
+ options.onImage?.();
31
+ },
32
+ });
33
+ }
34
+ if (options.onEmbed) {
35
+ items.push({
36
+ key: 'embed',
37
+ title: translate('richText.embed.add'),
38
+ keywords: 'embed component block',
39
+ icon: Blocks,
40
+ run: (editor, range) => {
41
+ editor.chain().focus().deleteRange(range).run();
42
+ options.onEmbed?.();
43
+ },
44
+ });
45
+ }
46
+ return items;
47
+ }
48
+ const SlashMenu = forwardRef((props, ref) => {
49
+ const [selected, setSelected] = useState(0);
50
+ useEffect(() => setSelected(0), [props.items]);
51
+ useImperativeHandle(ref, () => ({
52
+ onKeyDown: ({ event }) => {
53
+ const count = props.items.length;
54
+ if (count === 0)
55
+ return false;
56
+ if (event.key === 'ArrowUp') {
57
+ setSelected((s) => (s + count - 1) % count);
58
+ return true;
59
+ }
60
+ if (event.key === 'ArrowDown') {
61
+ setSelected((s) => (s + 1) % count);
62
+ return true;
63
+ }
64
+ if (event.key === 'Enter') {
65
+ const item = props.items[selected];
66
+ if (item)
67
+ props.command(item);
68
+ return true;
69
+ }
70
+ return false;
71
+ },
72
+ }));
73
+ if (props.items.length === 0) {
74
+ return (_jsx("div", { className: "ec-rte-slash", children: _jsx("span", { className: "px-2 py-1.5 text-xs text-muted-foreground", children: translate('richText.slash.empty') }) }));
75
+ }
76
+ return (_jsx("div", { className: "ec-rte-slash", role: "menu", children: props.items.map((item, i) => {
77
+ const Icon = item.icon;
78
+ return (_jsxs("button", { type: "button", role: "menuitem", onMouseEnter: () => setSelected(i), onMouseDown: (e) => {
79
+ e.preventDefault();
80
+ props.command(item);
81
+ }, className: cn('flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm outline-none', i === selected ? 'bg-muted' : 'hover:bg-muted/60'), children: [_jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: item.title })] }, item.key));
82
+ }) }));
83
+ });
84
+ SlashMenu.displayName = 'SlashMenu';
85
+ /** Position a floating element under the caret, flipping above near the viewport edge. */
86
+ function place(el, rect) {
87
+ if (!rect)
88
+ return;
89
+ const margin = 8;
90
+ const height = el.offsetHeight || 280;
91
+ const below = rect.bottom + 4;
92
+ const flip = below + height > window.innerHeight - margin && rect.top - height > margin;
93
+ el.style.position = 'fixed';
94
+ el.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`;
95
+ el.style.top = flip ? `${rect.top - height - 4}px` : `${below}px`;
96
+ el.style.zIndex = '60';
97
+ }
98
+ /** The suggestion config (everything but the `editor`, which the extension injects). */
99
+ function slashSuggestion(options) {
100
+ return {
101
+ char: '/',
102
+ startOfLine: false,
103
+ items: ({ query }) => {
104
+ const q = query.trim().toLowerCase();
105
+ const all = buildSlashItems(options);
106
+ if (!q)
107
+ return all;
108
+ return all.filter((item) => item.title.toLowerCase().includes(q) || item.keywords.includes(q));
109
+ },
110
+ command: ({ editor, range, props }) => props.run(editor, range),
111
+ render: () => {
112
+ let renderer = null;
113
+ let wrapper = null;
114
+ return {
115
+ onStart: (props) => {
116
+ renderer = new ReactRenderer(SlashMenu, {
117
+ props,
118
+ editor: props.editor,
119
+ });
120
+ wrapper = document.createElement('div');
121
+ wrapper.appendChild(renderer.element);
122
+ document.body.appendChild(wrapper);
123
+ place(wrapper, props.clientRect?.());
124
+ },
125
+ onUpdate: (props) => {
126
+ renderer?.updateProps(props);
127
+ if (wrapper)
128
+ place(wrapper, props.clientRect?.());
129
+ },
130
+ onKeyDown: (props) => {
131
+ if (props.event.key === 'Escape') {
132
+ wrapper?.remove();
133
+ wrapper = null;
134
+ return true;
135
+ }
136
+ return renderer?.ref?.onKeyDown(props) ?? false;
137
+ },
138
+ onExit: () => {
139
+ wrapper?.remove();
140
+ wrapper = null;
141
+ renderer?.destroy();
142
+ renderer = null;
143
+ },
144
+ };
145
+ },
146
+ };
147
+ }
148
+ /** Build the slash-command extension bound to the editor's shared media pickers. */
149
+ export function createSlashCommand(options) {
150
+ return Extension.create({
151
+ name: 'slashCommand',
152
+ addOptions: () => options,
153
+ addProseMirrorPlugins() {
154
+ return [Suggestion({ editor: this.editor, ...slashSuggestion(this.options) })];
155
+ },
156
+ });
157
+ }
158
+ //# sourceMappingURL=slash-command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slash-command.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/slash-command.tsx"],"names":[],"mappings":"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","sourcesContent":["import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'\nimport type { ComponentType } from 'react'\nimport { Extension, ReactRenderer } from '@tiptap/react'\nimport type { Editor, Range } from '@tiptap/react'\nimport Suggestion from '@tiptap/suggestion'\nimport type { SuggestionKeyDownProps, SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'\nimport {\n Blocks,\n Heading1,\n Heading2,\n Heading3,\n ImagePlus,\n List,\n ListOrdered,\n Minus,\n Pilcrow,\n Quote,\n SquareCode,\n} from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { cn } from '@elytracms/studio/lib/utils'\n\n/**\n * Notion-style slash menu (EC-314): typing `/` at the caret opens a filterable\n * command palette that inserts block-level content — headings, lists, quote, code,\n * divider — and, when available, an image or a component embed (delegated to the\n * editor's shared pickers via {@link SlashCommandOptions}). Built on TipTap's\n * `Suggestion` utility; the menu is a React overlay positioned at the caret.\n */\n\nexport interface SlashCommandOptions {\n /** Open the asset picker (omitted ⇒ no `/image` item). */\n onImage?: () => void\n /** Open the component-embed picker (omitted ⇒ no `/embed` item). */\n onEmbed?: () => void\n}\n\nexport interface SlashItem {\n key: string\n title: string\n keywords: string\n icon: LucideIcon\n run: (editor: Editor, range: Range) => void\n}\n\n/** The command list, gated by which media actions the editor provided. */\nexport function buildSlashItems(options: SlashCommandOptions): SlashItem[] {\n const block = (run: (chain: ReturnType<Editor['chain']>) => ReturnType<Editor['chain']>) =>\n (editor: Editor, range: Range) => run(editor.chain().focus().deleteRange(range)).run()\n\n const items: SlashItem[] = [\n { key: 'text', title: translate('richText.slash.text'), keywords: 'paragraph text plain', icon: Pilcrow, run: block((c) => c.setParagraph()) },\n { key: 'h1', title: translate('richText.heading', { level: 1 }), keywords: 'heading title h1', icon: Heading1, run: block((c) => c.toggleHeading({ level: 1 })) },\n { key: 'h2', title: translate('richText.heading', { level: 2 }), keywords: 'heading subtitle h2', icon: Heading2, run: block((c) => c.toggleHeading({ level: 2 })) },\n { key: 'h3', title: translate('richText.heading', { level: 3 }), keywords: 'heading h3', icon: Heading3, run: block((c) => c.toggleHeading({ level: 3 })) },\n { key: 'bulletList', title: translate('richText.bulletList'), keywords: 'bullet unordered list ul', icon: List, run: block((c) => c.toggleBulletList()) },\n { key: 'orderedList', title: translate('richText.orderedList'), keywords: 'ordered numbered list ol', icon: ListOrdered, run: block((c) => c.toggleOrderedList()) },\n { key: 'blockquote', title: translate('richText.blockquote'), keywords: 'quote blockquote', icon: Quote, run: block((c) => c.toggleBlockquote()) },\n { key: 'codeBlock', title: translate('richText.codeBlock'), keywords: 'code block pre', icon: SquareCode, run: block((c) => c.toggleCodeBlock()) },\n { key: 'divider', title: translate('richText.horizontalRule'), keywords: 'divider rule hr separator', icon: Minus, run: block((c) => c.setHorizontalRule()) },\n ]\n if (options.onImage) {\n items.push({\n key: 'image',\n title: translate('richText.image.add'),\n keywords: 'image picture photo media',\n icon: ImagePlus,\n run: (editor, range) => {\n editor.chain().focus().deleteRange(range).run()\n options.onImage?.()\n },\n })\n }\n if (options.onEmbed) {\n items.push({\n key: 'embed',\n title: translate('richText.embed.add'),\n keywords: 'embed component block',\n icon: Blocks,\n run: (editor, range) => {\n editor.chain().focus().deleteRange(range).run()\n options.onEmbed?.()\n },\n })\n }\n return items\n}\n\n/** Imperative handle the suggestion render loop calls for keyboard navigation. */\ninterface SlashMenuHandle {\n onKeyDown: (props: SuggestionKeyDownProps) => boolean\n}\n\nconst SlashMenu = forwardRef<SlashMenuHandle, SuggestionProps<SlashItem, SlashItem>>((props, ref) => {\n const [selected, setSelected] = useState(0)\n useEffect(() => setSelected(0), [props.items])\n\n useImperativeHandle(ref, () => ({\n onKeyDown: ({ event }) => {\n const count = props.items.length\n if (count === 0) return false\n if (event.key === 'ArrowUp') {\n setSelected((s) => (s + count - 1) % count)\n return true\n }\n if (event.key === 'ArrowDown') {\n setSelected((s) => (s + 1) % count)\n return true\n }\n if (event.key === 'Enter') {\n const item = props.items[selected]\n if (item) props.command(item)\n return true\n }\n return false\n },\n }))\n\n if (props.items.length === 0) {\n return (\n <div className=\"ec-rte-slash\">\n <span className=\"px-2 py-1.5 text-xs text-muted-foreground\">\n {translate('richText.slash.empty')}\n </span>\n </div>\n )\n }\n\n return (\n <div className=\"ec-rte-slash\" role=\"menu\">\n {props.items.map((item, i) => {\n const Icon = item.icon\n return (\n <button\n key={item.key}\n type=\"button\"\n role=\"menuitem\"\n onMouseEnter={() => setSelected(i)}\n onMouseDown={(e) => {\n e.preventDefault()\n props.command(item)\n }}\n className={cn(\n 'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm outline-none',\n i === selected ? 'bg-muted' : 'hover:bg-muted/60',\n )}\n >\n <Icon className=\"size-4 shrink-0 text-muted-foreground\" />\n <span className=\"truncate\">{item.title}</span>\n </button>\n )\n })}\n </div>\n )\n})\nSlashMenu.displayName = 'SlashMenu'\n\n/** Position a floating element under the caret, flipping above near the viewport edge. */\nfunction place(el: HTMLElement, rect: DOMRect | null | undefined): void {\n if (!rect) return\n const margin = 8\n const height = el.offsetHeight || 280\n const below = rect.bottom + 4\n const flip = below + height > window.innerHeight - margin && rect.top - height > margin\n el.style.position = 'fixed'\n el.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`\n el.style.top = flip ? `${rect.top - height - 4}px` : `${below}px`\n el.style.zIndex = '60'\n}\n\n/** The suggestion config (everything but the `editor`, which the extension injects). */\nfunction slashSuggestion(options: SlashCommandOptions): Omit<SuggestionOptions<SlashItem, SlashItem>, 'editor'> {\n return {\n char: '/',\n startOfLine: false,\n items: ({ query }) => {\n const q = query.trim().toLowerCase()\n const all = buildSlashItems(options)\n if (!q) return all\n return all.filter((item) => item.title.toLowerCase().includes(q) || item.keywords.includes(q))\n },\n command: ({ editor, range, props }) => props.run(editor, range),\n render: () => {\n let renderer: ReactRenderer<SlashMenuHandle, SuggestionProps<SlashItem, SlashItem>> | null = null\n let wrapper: HTMLDivElement | null = null\n\n return {\n onStart: (props) => {\n renderer = new ReactRenderer(SlashMenu as ComponentType<SuggestionProps<SlashItem, SlashItem>>, {\n props,\n editor: props.editor,\n })\n wrapper = document.createElement('div')\n wrapper.appendChild(renderer.element)\n document.body.appendChild(wrapper)\n place(wrapper, props.clientRect?.())\n },\n onUpdate: (props) => {\n renderer?.updateProps(props)\n if (wrapper) place(wrapper, props.clientRect?.())\n },\n onKeyDown: (props) => {\n if (props.event.key === 'Escape') {\n wrapper?.remove()\n wrapper = null\n return true\n }\n return renderer?.ref?.onKeyDown(props) ?? false\n },\n onExit: () => {\n wrapper?.remove()\n wrapper = null\n renderer?.destroy()\n renderer = null\n },\n }\n },\n }\n}\n\n/** Build the slash-command extension bound to the editor's shared media pickers. */\nexport function createSlashCommand(options: SlashCommandOptions) {\n return Extension.create<SlashCommandOptions>({\n name: 'slashCommand',\n addOptions: () => options,\n addProseMirrorPlugins() {\n return [Suggestion({ editor: this.editor, ...slashSuggestion(this.options) })]\n },\n })\n}\n"]}
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from 'react';
2
+ interface ToolbarButtonProps {
3
+ active?: boolean;
4
+ disabled?: boolean;
5
+ label: string;
6
+ onClick: () => void;
7
+ children: ReactNode;
8
+ }
9
+ /**
10
+ * A single rich-text toolbar toggle (EC-314): pressed state, tooltip, and a
11
+ * selection-preserving click (`onMouseDown` default-prevented so clicking the button
12
+ * never blurs the editor or collapses the current selection).
13
+ */
14
+ export declare function ToolbarButton({ active, disabled, label, onClick, children }: ToolbarButtonProps): import("react").JSX.Element;
15
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Button } from '@elytracms/studio/ui';
3
+ /**
4
+ * A single rich-text toolbar toggle (EC-314): pressed state, tooltip, and a
5
+ * selection-preserving click (`onMouseDown` default-prevented so clicking the button
6
+ * never blurs the editor or collapses the current selection).
7
+ */
8
+ export function ToolbarButton({ active = false, disabled, label, onClick, children }) {
9
+ return (_jsx(Button, { type: "button", variant: active ? 'secondary' : 'ghost', size: "icon-sm", disabled: disabled, "aria-label": label, "aria-pressed": active, title: label, onMouseDown: (e) => e.preventDefault(), onClick: onClick, children: children }));
10
+ }
11
+ //# sourceMappingURL=toolbar-button.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"toolbar-button.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/toolbar-button.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAU7C;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,EAAE,MAAM,GAAG,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAsB;IACtG,OAAO,CACL,KAAC,MAAM,IACL,IAAI,EAAC,QAAQ,EACb,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,OAAO,EACvC,IAAI,EAAC,SAAS,EACd,QAAQ,EAAE,QAAQ,gBACN,KAAK,kBACH,MAAM,EACpB,KAAK,EAAE,KAAK,EACZ,WAAW,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,cAAc,EAAE,EACtC,OAAO,EAAE,OAAO,YAEf,QAAQ,GACF,CACV,CAAA;AACH,CAAC","sourcesContent":["import type { ReactNode } from 'react'\nimport { Button } from '@elytracms/studio/ui'\n\ninterface ToolbarButtonProps {\n active?: boolean\n disabled?: boolean\n label: string\n onClick: () => void\n children: ReactNode\n}\n\n/**\n * A single rich-text toolbar toggle (EC-314): pressed state, tooltip, and a\n * selection-preserving click (`onMouseDown` default-prevented so clicking the button\n * never blurs the editor or collapses the current selection).\n */\nexport function ToolbarButton({ active = false, disabled, label, onClick, children }: ToolbarButtonProps) {\n return (\n <Button\n type=\"button\"\n variant={active ? 'secondary' : 'ghost'}\n size=\"icon-sm\"\n disabled={disabled}\n aria-label={label}\n aria-pressed={active}\n title={label}\n onMouseDown={(e) => e.preventDefault()}\n onClick={onClick}\n >\n {children}\n </Button>\n )\n}\n"]}
@@ -0,0 +1,19 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import type { LinkTarget } from './link-control';
3
+ interface RichTextToolbarProps {
4
+ editor: Editor;
5
+ /** Internal-link destinations across the workspace (empty ⇒ external-only). */
6
+ targets: LinkTarget[];
7
+ /** Open the shared image picker (omitted ⇒ no image button). */
8
+ onOpenImage?: () => void;
9
+ /** Open the shared component-embed picker (omitted ⇒ no embed button). */
10
+ onOpenEmbed?: () => void;
11
+ }
12
+ /**
13
+ * The CMS rich-text toolbar (EC-314) — a compact, Notion-grade control strip that
14
+ * reads its active/enabled state reactively from the editor (`useEditorState`) so a
15
+ * pressed control always mirrors the caret. Every button prevents the default
16
+ * mousedown so clicking it never steals the editor selection.
17
+ */
18
+ export declare function RichTextToolbar({ editor, targets, onOpenImage, onOpenEmbed }: RichTextToolbarProps): import("react").JSX.Element;
19
+ export {};
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEditorState } from '@tiptap/react';
3
+ import { Blocks, Bold, ChevronDown, Code, ImagePlus, Italic, List, ListOrdered, Minus, Pilcrow, Quote, Redo2, SquareCode, Strikethrough, Underline, Undo2, } from 'lucide-react';
4
+ import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Toolbar, ToolbarGroup, ToolbarSeparator, } from '@elytracms/studio/ui';
5
+ import { translate } from '@elytracms/studio/lib/i18n';
6
+ import { ToolbarButton } from './toolbar-button';
7
+ import { LinkControl } from './link-control';
8
+ /**
9
+ * The CMS rich-text toolbar (EC-314) — a compact, Notion-grade control strip that
10
+ * reads its active/enabled state reactively from the editor (`useEditorState`) so a
11
+ * pressed control always mirrors the caret. Every button prevents the default
12
+ * mousedown so clicking it never steals the editor selection.
13
+ */
14
+ export function RichTextToolbar({ editor, targets, onOpenImage, onOpenEmbed }) {
15
+ const state = useEditorState({
16
+ editor,
17
+ selector: ({ editor }) => ({
18
+ bold: editor.isActive('bold'),
19
+ italic: editor.isActive('italic'),
20
+ underline: editor.isActive('underline'),
21
+ strike: editor.isActive('strike'),
22
+ code: editor.isActive('code'),
23
+ bulletList: editor.isActive('bulletList'),
24
+ orderedList: editor.isActive('orderedList'),
25
+ blockquote: editor.isActive('blockquote'),
26
+ codeBlock: editor.isActive('codeBlock'),
27
+ paragraph: editor.isActive('paragraph'),
28
+ headingLevel: [1, 2, 3, 4, 5, 6].find((l) => editor.isActive('heading', { level: l })) ?? null,
29
+ canUndo: editor.can().undo(),
30
+ canRedo: editor.can().redo(),
31
+ }),
32
+ });
33
+ const styleLabel = state.headingLevel != null
34
+ ? translate('richText.heading', { level: state.headingLevel })
35
+ : translate('richText.paragraph');
36
+ return (_jsxs(Toolbar, { className: "h-10 flex-wrap gap-0.5 rounded-none border-x-0 border-t-0 bg-muted/30 px-1.5", children: [_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 gap-1 px-2 font-medium", onMouseDown: (e) => e.preventDefault(), children: [styleLabel, _jsx(ChevronDown, { className: "size-3 opacity-60" })] }) }), _jsxs(DropdownMenuContent, { align: "start", className: "min-w-40", children: [_jsxs(DropdownMenuItem, { onSelect: () => editor.chain().focus().setParagraph().run(), children: [_jsx(Pilcrow, { className: "size-3.5" }), " ", translate('richText.paragraph')] }), [1, 2, 3].map((level) => (_jsxs(DropdownMenuItem, { onSelect: () => editor.chain().focus().toggleHeading({ level }).run(), children: [_jsxs("span", { className: "w-3.5 text-center text-xs font-semibold", children: ["H", level] }), ' ', translate('richText.heading', { level })] }, level)))] })] }), _jsx(ToolbarSeparator, {}), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { active: state.bold, label: translate('richText.bold'), onClick: () => editor.chain().focus().toggleBold().run(), children: _jsx(Bold, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: state.italic, label: translate('richText.italic'), onClick: () => editor.chain().focus().toggleItalic().run(), children: _jsx(Italic, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: state.underline, label: translate('richText.underline'), onClick: () => editor.chain().focus().toggleUnderline().run(), children: _jsx(Underline, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: state.strike, label: translate('richText.strike'), onClick: () => editor.chain().focus().toggleStrike().run(), children: _jsx(Strikethrough, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: state.code, label: translate('richText.code'), onClick: () => editor.chain().focus().toggleCode().run(), children: _jsx(Code, { className: "size-3.5" }) })] }), _jsx(ToolbarSeparator, {}), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { active: state.bulletList, label: translate('richText.bulletList'), onClick: () => editor.chain().focus().toggleBulletList().run(), children: _jsx(List, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: state.orderedList, label: translate('richText.orderedList'), onClick: () => editor.chain().focus().toggleOrderedList().run(), children: _jsx(ListOrdered, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: state.blockquote, label: translate('richText.blockquote'), onClick: () => editor.chain().focus().toggleBlockquote().run(), children: _jsx(Quote, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: state.codeBlock, label: translate('richText.codeBlock'), onClick: () => editor.chain().focus().toggleCodeBlock().run(), children: _jsx(SquareCode, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: false, label: translate('richText.horizontalRule'), onClick: () => editor.chain().focus().setHorizontalRule().run(), children: _jsx(Minus, { className: "size-3.5" }) })] }), _jsx(ToolbarSeparator, {}), _jsxs(ToolbarGroup, { children: [_jsx(LinkControl, { editor: editor, targets: targets }), onOpenImage ? (_jsx(ToolbarButton, { label: translate('richText.image.add'), onClick: onOpenImage, children: _jsx(ImagePlus, { className: "size-3.5" }) })) : null, onOpenEmbed ? (_jsx(ToolbarButton, { label: translate('richText.embed.add'), onClick: onOpenEmbed, children: _jsx(Blocks, { className: "size-3.5" }) })) : null] }), _jsx("div", { className: "flex-1" }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { active: false, disabled: !state.canUndo, label: translate('richText.undo'), onClick: () => editor.chain().focus().undo().run(), children: _jsx(Undo2, { className: "size-3.5" }) }), _jsx(ToolbarButton, { active: false, disabled: !state.canRedo, label: translate('richText.redo'), onClick: () => editor.chain().focus().redo().run(), children: _jsx(Redo2, { className: "size-3.5" }) })] })] }));
37
+ }
38
+ //# sourceMappingURL=toolbar.js.map