@elytracms/studio 0.0.35 → 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/assets/asset-selection.d.ts +1 -1
- package/dist/components/assets/asset-selection.js +2 -2
- package/dist/components/assets/asset-selection.js.map +1 -1
- package/dist/components/builder/clipboard-actions.js +1 -1
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/composition-field-editor.js +7 -7
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/inline-edit.js +4 -3
- package/dist/components/builder/inline-edit.js.map +1 -1
- package/dist/components/builder/insert-context.js +1 -1
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/layout-inspector.js +2 -2
- package/dist/components/builder/layout-inspector.js.map +1 -1
- package/dist/components/builder/node-factory.js +7 -7
- package/dist/components/builder/node-factory.js.map +1 -1
- package/dist/components/builder/page-tree.js +1 -1
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/property-inspector.js +2 -2
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/reuse-context.js +2 -2
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.js +5 -4
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/cell.js +1 -1
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/editable-cell.js +4 -4
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-control.js +11 -10
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/relation-picker.d.ts +2 -2
- package/dist/components/cms/relation-picker.js +5 -5
- package/dist/components/cms/relation-picker.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/components/components/component-detail.js +1 -1
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/components-index.js +1 -1
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/components/components/registry-browser.js +2 -2
- package/dist/components/components/registry-browser.js.map +1 -1
- package/dist/components/extensions/extensions-view.js +1 -1
- package/dist/components/extensions/extensions-view.js.map +1 -1
- package/dist/lib/clipboard/paste-target.js +1 -1
- package/dist/lib/clipboard/paste-target.js.map +1 -1
- package/dist/lib/dnd/drop-rules.js +2 -2
- package/dist/lib/dnd/drop-rules.js.map +1 -1
- package/dist/lib/dnd/gap-drag.js +2 -2
- package/dist/lib/dnd/gap-drag.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +33 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +2 -2
- package/dist/lib/persona/labels.js +3 -3
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/references/reference-model.js +1 -1
- package/dist/lib/references/reference-model.js.map +1 -1
- package/dist/lib/registry/preview.js +15 -15
- package/dist/lib/registry/preview.js.map +1 -1
- package/dist/lib/registry/project-components.js +6 -13
- package/dist/lib/registry/project-components.js.map +1 -1
- package/package.json +8 -2
- package/src/styles/studio.css +105 -0
|
@@ -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,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,20 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/react';
|
|
2
|
+
import type { AssetsStore } from '../../assets/assets-store';
|
|
3
|
+
import type { LinkTarget } from './link-control';
|
|
4
|
+
interface RichTextToolbarProps {
|
|
5
|
+
editor: Editor;
|
|
6
|
+
/** Internal-link destinations across the workspace (empty ⇒ external-only). */
|
|
7
|
+
targets: LinkTarget[];
|
|
8
|
+
/** The asset library, when present (enables image insertion). */
|
|
9
|
+
assets: AssetsStore | null;
|
|
10
|
+
/** The field's embed `allow` list (component ids editors may embed). */
|
|
11
|
+
allow?: string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The CMS rich-text toolbar (EC-314) — a compact, Notion-grade control strip that
|
|
15
|
+
* reads its active/enabled state reactively from the editor (`useEditorState`) so a
|
|
16
|
+
* pressed control always mirrors the caret. Every button prevents the default
|
|
17
|
+
* mousedown so clicking it never steals the editor selection.
|
|
18
|
+
*/
|
|
19
|
+
export declare function RichTextToolbar({ editor, targets, assets, allow }: RichTextToolbarProps): import("react").JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEditorState } from '@tiptap/react';
|
|
3
|
+
import { Bold, ChevronDown, Code, 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
|
+
import { ImageControl } from './image-control';
|
|
9
|
+
import { EmbedControl } from './embed-control';
|
|
10
|
+
/**
|
|
11
|
+
* The CMS rich-text toolbar (EC-314) — a compact, Notion-grade control strip that
|
|
12
|
+
* reads its active/enabled state reactively from the editor (`useEditorState`) so a
|
|
13
|
+
* pressed control always mirrors the caret. Every button prevents the default
|
|
14
|
+
* mousedown so clicking it never steals the editor selection.
|
|
15
|
+
*/
|
|
16
|
+
export function RichTextToolbar({ editor, targets, assets, allow }) {
|
|
17
|
+
const state = useEditorState({
|
|
18
|
+
editor,
|
|
19
|
+
selector: ({ editor }) => ({
|
|
20
|
+
bold: editor.isActive('bold'),
|
|
21
|
+
italic: editor.isActive('italic'),
|
|
22
|
+
underline: editor.isActive('underline'),
|
|
23
|
+
strike: editor.isActive('strike'),
|
|
24
|
+
code: editor.isActive('code'),
|
|
25
|
+
bulletList: editor.isActive('bulletList'),
|
|
26
|
+
orderedList: editor.isActive('orderedList'),
|
|
27
|
+
blockquote: editor.isActive('blockquote'),
|
|
28
|
+
codeBlock: editor.isActive('codeBlock'),
|
|
29
|
+
paragraph: editor.isActive('paragraph'),
|
|
30
|
+
headingLevel: [1, 2, 3, 4, 5, 6].find((l) => editor.isActive('heading', { level: l })) ?? null,
|
|
31
|
+
canUndo: editor.can().undo(),
|
|
32
|
+
canRedo: editor.can().redo(),
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
const styleLabel = state.headingLevel != null
|
|
36
|
+
? translate('richText.heading', { level: state.headingLevel })
|
|
37
|
+
: translate('richText.paragraph');
|
|
38
|
+
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 }), _jsx(ImageControl, { editor: editor, assets: assets }), _jsx(EmbedControl, { editor: editor, allow: allow })] }), _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" }) })] })] }));
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=toolbar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toolbar.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/toolbar.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useEditorState } from '@tiptap/react'\nimport type { Editor } from '@tiptap/react'\nimport {\n Bold,\n ChevronDown,\n Code,\n Italic,\n List,\n ListOrdered,\n Minus,\n Pilcrow,\n Quote,\n Redo2,\n SquareCode,\n Strikethrough,\n Underline,\n Undo2,\n} from 'lucide-react'\nimport {\n Button,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n Toolbar,\n ToolbarGroup,\n ToolbarSeparator,\n} from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport type { AssetsStore } from '../../assets/assets-store'\nimport { ToolbarButton } from './toolbar-button'\nimport { LinkControl } from './link-control'\nimport type { LinkTarget } from './link-control'\nimport { ImageControl } from './image-control'\nimport { EmbedControl } from './embed-control'\n\ninterface RichTextToolbarProps {\n editor: Editor\n /** Internal-link destinations across the workspace (empty ⇒ external-only). */\n targets: LinkTarget[]\n /** The asset library, when present (enables image insertion). */\n assets: AssetsStore | null\n /** The field's embed `allow` list (component ids editors may embed). */\n allow?: string[]\n}\n\n/** Snapshot of the active formatting state, recomputed reactively per selection. */\ninterface ToolbarState {\n bold: boolean\n italic: boolean\n underline: boolean\n strike: boolean\n code: boolean\n bulletList: boolean\n orderedList: boolean\n blockquote: boolean\n codeBlock: boolean\n paragraph: boolean\n headingLevel: number | null\n canUndo: boolean\n canRedo: boolean\n}\n\n/**\n * The CMS rich-text toolbar (EC-314) — a compact, Notion-grade control strip that\n * reads its active/enabled state reactively from the editor (`useEditorState`) so a\n * pressed control always mirrors the caret. Every button prevents the default\n * mousedown so clicking it never steals the editor selection.\n */\nexport function RichTextToolbar({ editor, targets, assets, allow }: RichTextToolbarProps) {\n const state = useEditorState({\n editor,\n selector: ({ editor }): ToolbarState => ({\n bold: editor.isActive('bold'),\n italic: editor.isActive('italic'),\n underline: editor.isActive('underline'),\n strike: editor.isActive('strike'),\n code: editor.isActive('code'),\n bulletList: editor.isActive('bulletList'),\n orderedList: editor.isActive('orderedList'),\n blockquote: editor.isActive('blockquote'),\n codeBlock: editor.isActive('codeBlock'),\n paragraph: editor.isActive('paragraph'),\n headingLevel: [1, 2, 3, 4, 5, 6].find((l) => editor.isActive('heading', { level: l })) ?? null,\n canUndo: editor.can().undo(),\n canRedo: editor.can().redo(),\n }),\n })\n\n const styleLabel =\n state.headingLevel != null\n ? translate('richText.heading', { level: state.headingLevel })\n : translate('richText.paragraph')\n\n return (\n <Toolbar className=\"h-10 flex-wrap gap-0.5 rounded-none border-x-0 border-t-0 bg-muted/30 px-1.5\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-7 gap-1 px-2 font-medium\"\n onMouseDown={(e) => e.preventDefault()}\n >\n {styleLabel}\n <ChevronDown className=\"size-3 opacity-60\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"start\" className=\"min-w-40\">\n <DropdownMenuItem onSelect={() => editor.chain().focus().setParagraph().run()}>\n <Pilcrow className=\"size-3.5\" /> {translate('richText.paragraph')}\n </DropdownMenuItem>\n {([1, 2, 3] as const).map((level) => (\n <DropdownMenuItem\n key={level}\n onSelect={() => editor.chain().focus().toggleHeading({ level }).run()}\n >\n <span className=\"w-3.5 text-center text-xs font-semibold\">H{level}</span>{' '}\n {translate('richText.heading', { level })}\n </DropdownMenuItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n\n <ToolbarSeparator />\n\n <ToolbarGroup>\n <ToolbarButton\n active={state.bold}\n label={translate('richText.bold')}\n onClick={() => editor.chain().focus().toggleBold().run()}\n >\n <Bold className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={state.italic}\n label={translate('richText.italic')}\n onClick={() => editor.chain().focus().toggleItalic().run()}\n >\n <Italic className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={state.underline}\n label={translate('richText.underline')}\n onClick={() => editor.chain().focus().toggleUnderline().run()}\n >\n <Underline className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={state.strike}\n label={translate('richText.strike')}\n onClick={() => editor.chain().focus().toggleStrike().run()}\n >\n <Strikethrough className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={state.code}\n label={translate('richText.code')}\n onClick={() => editor.chain().focus().toggleCode().run()}\n >\n <Code className=\"size-3.5\" />\n </ToolbarButton>\n </ToolbarGroup>\n\n <ToolbarSeparator />\n\n <ToolbarGroup>\n <ToolbarButton\n active={state.bulletList}\n label={translate('richText.bulletList')}\n onClick={() => editor.chain().focus().toggleBulletList().run()}\n >\n <List className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={state.orderedList}\n label={translate('richText.orderedList')}\n onClick={() => editor.chain().focus().toggleOrderedList().run()}\n >\n <ListOrdered className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={state.blockquote}\n label={translate('richText.blockquote')}\n onClick={() => editor.chain().focus().toggleBlockquote().run()}\n >\n <Quote className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={state.codeBlock}\n label={translate('richText.codeBlock')}\n onClick={() => editor.chain().focus().toggleCodeBlock().run()}\n >\n <SquareCode className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={false}\n label={translate('richText.horizontalRule')}\n onClick={() => editor.chain().focus().setHorizontalRule().run()}\n >\n <Minus className=\"size-3.5\" />\n </ToolbarButton>\n </ToolbarGroup>\n\n <ToolbarSeparator />\n\n <ToolbarGroup>\n <LinkControl editor={editor} targets={targets} />\n <ImageControl editor={editor} assets={assets} />\n <EmbedControl editor={editor} allow={allow} />\n </ToolbarGroup>\n\n <div className=\"flex-1\" />\n\n <ToolbarGroup>\n <ToolbarButton\n active={false}\n disabled={!state.canUndo}\n label={translate('richText.undo')}\n onClick={() => editor.chain().focus().undo().run()}\n >\n <Undo2 className=\"size-3.5\" />\n </ToolbarButton>\n <ToolbarButton\n active={false}\n disabled={!state.canRedo}\n label={translate('richText.redo')}\n onClick={() => editor.chain().focus().redo().run()}\n >\n <Redo2 className=\"size-3.5\" />\n </ToolbarButton>\n </ToolbarGroup>\n </Toolbar>\n )\n}\n"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RichTextDoc, RichTextValue } from '@elytracms/core/rich-text';
|
|
2
|
+
/**
|
|
3
|
+
* Interpret whatever a `richText` field currently holds as an editor document.
|
|
4
|
+
*
|
|
5
|
+
* Accepts the canonical `{ version, doc }` object, its JSON-string form, a bare
|
|
6
|
+
* `doc`, and — for graceful migration off the retired textarea stub — a plain
|
|
7
|
+
* string, which becomes a single paragraph. Anything unparseable becomes an empty
|
|
8
|
+
* document (never throws; validation is first-class elsewhere).
|
|
9
|
+
*/
|
|
10
|
+
export declare function toEditorDoc(value: unknown): RichTextDoc;
|
|
11
|
+
/** Wrap an editor document back into a stamped, versioned storage value. */
|
|
12
|
+
export declare function richTextValueFromDoc(doc: RichTextDoc): RichTextValue;
|
|
13
|
+
/**
|
|
14
|
+
* Prune the `null` attribute values ProseMirror stamps from declared-but-unset
|
|
15
|
+
* attributes, and drop the resulting empty `attrs` bags, so the editor's output
|
|
16
|
+
* matches the lean storage convention. Recurses `content`; leaves `marks` attrs
|
|
17
|
+
* pruned the same way. Pure; returns a fresh tree.
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeEditorDoc(doc: RichTextDoc): RichTextDoc;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a document carries no author-visible content — only empty paragraphs or
|
|
22
|
+
* headings (TipTap keeps a mandatory trailing block even when the field is blank).
|
|
23
|
+
* Any text, atom (image/embed/hardBreak/hr), list, or blockquote makes it non-empty.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isEffectivelyEmpty(doc: RichTextDoc): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the (already normalized) editor document is semantically the stored one —
|
|
28
|
+
* so the editor can re-emit the exact stored value instead of a re-serialization,
|
|
29
|
+
* keeping the field un-dirtied. Two empty documents are always equivalent (the
|
|
30
|
+
* trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not a
|
|
31
|
+
* change); otherwise defers to the shared structural comparison.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isEquivalentToStored(current: RichTextDoc, storedValue: unknown): boolean;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createRichTextValue, parseRichTextValue, richTextDocsEquivalent, } from '@elytracms/core/rich-text';
|
|
2
|
+
/**
|
|
3
|
+
* Storage ⟷ editor bridging for the CMS TipTap editor (EC-314).
|
|
4
|
+
*
|
|
5
|
+
* The storage format (`@elytracms/core/rich-text` `RichTextValue`) is a versioned,
|
|
6
|
+
* ProseMirror-shaped `{ version, doc }` envelope — the *same* JSON TipTap's
|
|
7
|
+
* `editor.getJSON()` produces for its document. So the bridge is thin: unwrap the
|
|
8
|
+
* `doc` to feed TipTap, wrap it back on the way out. Two concerns make it more than
|
|
9
|
+
* an identity function, both handled here so the editor component stays declarative:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Lean canonicalisation.** ProseMirror stamps every declared attribute onto a
|
|
12
|
+
* node/mark, so TipTap emits `null` defaults the lean storage convention omits
|
|
13
|
+
* (`link` → `ref: null`, `codeBlock` → `language: null`, `image` → `title: null`).
|
|
14
|
+
* {@link normalizeEditorDoc} prunes those so an untouched document round-trips to
|
|
15
|
+
* the same shape the extractor/renderer already read — a bare external `link`
|
|
16
|
+
* stays `{ href }`, never `{ href, ref: null }`.
|
|
17
|
+
*
|
|
18
|
+
* 2. **No false dirty.** Opening a field must not mark it changed, and typing then
|
|
19
|
+
* undoing back to the original must clear dirty. {@link isEquivalentToStored}
|
|
20
|
+
* compares semantically (via the shared `richTextDocsEquivalent`, plus an
|
|
21
|
+
* empty-document tolerance for TipTap's mandatory trailing paragraph) so the
|
|
22
|
+
* editor can re-emit the *exact stored value* when nothing really changed.
|
|
23
|
+
*/
|
|
24
|
+
/** ProseMirror node types that carry no reference/atom payload and render as prose. */
|
|
25
|
+
const EMPTY_TOLERANT_BLOCKS = new Set(['paragraph', 'heading']);
|
|
26
|
+
/**
|
|
27
|
+
* Interpret whatever a `richText` field currently holds as an editor document.
|
|
28
|
+
*
|
|
29
|
+
* Accepts the canonical `{ version, doc }` object, its JSON-string form, a bare
|
|
30
|
+
* `doc`, and — for graceful migration off the retired textarea stub — a plain
|
|
31
|
+
* string, which becomes a single paragraph. Anything unparseable becomes an empty
|
|
32
|
+
* document (never throws; validation is first-class elsewhere).
|
|
33
|
+
*/
|
|
34
|
+
export function toEditorDoc(value) {
|
|
35
|
+
const parsed = parseRichTextValue(value);
|
|
36
|
+
if (parsed.ok)
|
|
37
|
+
return parsed.value.doc;
|
|
38
|
+
// A bare doc (no version envelope) — accept it directly.
|
|
39
|
+
if (isDocShape(value))
|
|
40
|
+
return value;
|
|
41
|
+
// Legacy plaintext written by the old textarea stub → one paragraph.
|
|
42
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
43
|
+
return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: value }] }] };
|
|
44
|
+
}
|
|
45
|
+
return { type: 'doc', content: [] };
|
|
46
|
+
}
|
|
47
|
+
function isDocShape(value) {
|
|
48
|
+
return (typeof value === 'object' &&
|
|
49
|
+
value !== null &&
|
|
50
|
+
value.type === 'doc');
|
|
51
|
+
}
|
|
52
|
+
/** Wrap an editor document back into a stamped, versioned storage value. */
|
|
53
|
+
export function richTextValueFromDoc(doc) {
|
|
54
|
+
return createRichTextValue(doc);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Prune the `null` attribute values ProseMirror stamps from declared-but-unset
|
|
58
|
+
* attributes, and drop the resulting empty `attrs` bags, so the editor's output
|
|
59
|
+
* matches the lean storage convention. Recurses `content`; leaves `marks` attrs
|
|
60
|
+
* pruned the same way. Pure; returns a fresh tree.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeEditorDoc(doc) {
|
|
63
|
+
return { type: 'doc', content: (doc.content ?? []).map(pruneNode) };
|
|
64
|
+
}
|
|
65
|
+
function pruneNode(node) {
|
|
66
|
+
const out = { type: node.type };
|
|
67
|
+
const attrs = pruneAttrs(node.attrs);
|
|
68
|
+
if (attrs)
|
|
69
|
+
out.attrs = attrs;
|
|
70
|
+
if (typeof node.text === 'string')
|
|
71
|
+
out.text = node.text;
|
|
72
|
+
if (node.marks && node.marks.length > 0) {
|
|
73
|
+
out.marks = node.marks.map((mark) => {
|
|
74
|
+
const markAttrs = pruneAttrs(mark.attrs);
|
|
75
|
+
return markAttrs ? { type: mark.type, attrs: markAttrs } : { type: mark.type };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (node.content)
|
|
79
|
+
out.content = node.content.map(pruneNode);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function pruneAttrs(attrs) {
|
|
83
|
+
if (!attrs)
|
|
84
|
+
return undefined;
|
|
85
|
+
const entries = Object.entries(attrs).filter(([, v]) => v !== null && v !== undefined);
|
|
86
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether a document carries no author-visible content — only empty paragraphs or
|
|
90
|
+
* headings (TipTap keeps a mandatory trailing block even when the field is blank).
|
|
91
|
+
* Any text, atom (image/embed/hardBreak/hr), list, or blockquote makes it non-empty.
|
|
92
|
+
*/
|
|
93
|
+
export function isEffectivelyEmpty(doc) {
|
|
94
|
+
return (doc.content ?? []).every(isEmptyBlock);
|
|
95
|
+
}
|
|
96
|
+
function isEmptyBlock(node) {
|
|
97
|
+
if (!EMPTY_TOLERANT_BLOCKS.has(node.type))
|
|
98
|
+
return false;
|
|
99
|
+
return (node.content ?? []).every((child) => child.type === 'text' && (child.text ?? '').length === 0);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether the (already normalized) editor document is semantically the stored one —
|
|
103
|
+
* so the editor can re-emit the exact stored value instead of a re-serialization,
|
|
104
|
+
* keeping the field un-dirtied. Two empty documents are always equivalent (the
|
|
105
|
+
* trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not a
|
|
106
|
+
* change); otherwise defers to the shared structural comparison.
|
|
107
|
+
*/
|
|
108
|
+
export function isEquivalentToStored(current, storedValue) {
|
|
109
|
+
const stored = toEditorDoc(storedValue);
|
|
110
|
+
if (isEffectivelyEmpty(current) && isEffectivelyEmpty(stored))
|
|
111
|
+
return true;
|
|
112
|
+
return richTextDocsEquivalent(current, stored);
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=value.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"value.js","sourceRoot":"","sources":["../../../../src/components/cms/rich-text/value.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n createRichTextValue,\n parseRichTextValue,\n richTextDocsEquivalent,\n} from '@elytracms/core/rich-text'\nimport type { RichTextDoc, RichTextNode, RichTextValue } from '@elytracms/core/rich-text'\n\n/**\n * Storage ⟷ editor bridging for the CMS TipTap editor (EC-314).\n *\n * The storage format (`@elytracms/core/rich-text` `RichTextValue`) is a versioned,\n * ProseMirror-shaped `{ version, doc }` envelope — the *same* JSON TipTap's\n * `editor.getJSON()` produces for its document. So the bridge is thin: unwrap the\n * `doc` to feed TipTap, wrap it back on the way out. Two concerns make it more than\n * an identity function, both handled here so the editor component stays declarative:\n *\n * 1. **Lean canonicalisation.** ProseMirror stamps every declared attribute onto a\n * node/mark, so TipTap emits `null` defaults the lean storage convention omits\n * (`link` → `ref: null`, `codeBlock` → `language: null`, `image` → `title: null`).\n * {@link normalizeEditorDoc} prunes those so an untouched document round-trips to\n * the same shape the extractor/renderer already read — a bare external `link`\n * stays `{ href }`, never `{ href, ref: null }`.\n *\n * 2. **No false dirty.** Opening a field must not mark it changed, and typing then\n * undoing back to the original must clear dirty. {@link isEquivalentToStored}\n * compares semantically (via the shared `richTextDocsEquivalent`, plus an\n * empty-document tolerance for TipTap's mandatory trailing paragraph) so the\n * editor can re-emit the *exact stored value* when nothing really changed.\n */\n\n/** ProseMirror node types that carry no reference/atom payload and render as prose. */\nconst EMPTY_TOLERANT_BLOCKS = new Set(['paragraph', 'heading'])\n\n/**\n * Interpret whatever a `richText` field currently holds as an editor document.\n *\n * Accepts the canonical `{ version, doc }` object, its JSON-string form, a bare\n * `doc`, and — for graceful migration off the retired textarea stub — a plain\n * string, which becomes a single paragraph. Anything unparseable becomes an empty\n * document (never throws; validation is first-class elsewhere).\n */\nexport function toEditorDoc(value: unknown): RichTextDoc {\n const parsed = parseRichTextValue(value)\n if (parsed.ok) return parsed.value.doc\n // A bare doc (no version envelope) — accept it directly.\n if (isDocShape(value)) return value\n // Legacy plaintext written by the old textarea stub → one paragraph.\n if (typeof value === 'string' && value.trim().length > 0) {\n return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: value }] }] }\n }\n return { type: 'doc', content: [] }\n}\n\nfunction isDocShape(value: unknown): value is RichTextDoc {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { type?: unknown }).type === 'doc'\n )\n}\n\n/** Wrap an editor document back into a stamped, versioned storage value. */\nexport function richTextValueFromDoc(doc: RichTextDoc): RichTextValue {\n return createRichTextValue(doc)\n}\n\n/**\n * Prune the `null` attribute values ProseMirror stamps from declared-but-unset\n * attributes, and drop the resulting empty `attrs` bags, so the editor's output\n * matches the lean storage convention. Recurses `content`; leaves `marks` attrs\n * pruned the same way. Pure; returns a fresh tree.\n */\nexport function normalizeEditorDoc(doc: RichTextDoc): RichTextDoc {\n return { type: 'doc', content: (doc.content ?? []).map(pruneNode) }\n}\n\nfunction pruneNode(node: RichTextNode): RichTextNode {\n const out: RichTextNode = { type: node.type }\n const attrs = pruneAttrs(node.attrs)\n if (attrs) out.attrs = attrs\n if (typeof node.text === 'string') out.text = node.text\n if (node.marks && node.marks.length > 0) {\n out.marks = node.marks.map((mark) => {\n const markAttrs = pruneAttrs(mark.attrs)\n return markAttrs ? { type: mark.type, attrs: markAttrs } : { type: mark.type }\n })\n }\n if (node.content) out.content = node.content.map(pruneNode)\n return out\n}\n\nfunction pruneAttrs(\n attrs: Record<string, unknown> | undefined,\n): Record<string, never> | undefined {\n if (!attrs) return undefined\n const entries = Object.entries(attrs).filter(([, v]) => v !== null && v !== undefined)\n return entries.length > 0 ? (Object.fromEntries(entries) as Record<string, never>) : undefined\n}\n\n/**\n * Whether a document carries no author-visible content — only empty paragraphs or\n * headings (TipTap keeps a mandatory trailing block even when the field is blank).\n * Any text, atom (image/embed/hardBreak/hr), list, or blockquote makes it non-empty.\n */\nexport function isEffectivelyEmpty(doc: RichTextDoc): boolean {\n return (doc.content ?? []).every(isEmptyBlock)\n}\n\nfunction isEmptyBlock(node: RichTextNode): boolean {\n if (!EMPTY_TOLERANT_BLOCKS.has(node.type)) return false\n return (node.content ?? []).every(\n (child) => child.type === 'text' && (child.text ?? '').length === 0,\n )\n}\n\n/**\n * Whether the (already normalized) editor document is semantically the stored one —\n * so the editor can re-emit the exact stored value instead of a re-serialization,\n * keeping the field un-dirtied. Two empty documents are always equivalent (the\n * trailing-paragraph difference between `content: []` and `[{ paragraph }]` is not a\n * change); otherwise defers to the shared structural comparison.\n */\nexport function isEquivalentToStored(current: RichTextDoc, storedValue: unknown): boolean {\n const stored = toEditorDoc(storedValue)\n if (isEffectivelyEmpty(current) && isEffectivelyEmpty(stored)) return true\n return richTextDocsEquivalent(current, stored)\n}\n"]}
|
|
@@ -25,7 +25,7 @@ export function ComponentDetail({ projectId, componentId, }) {
|
|
|
25
25
|
return (_jsx("div", { className: "grid h-full place-items-center p-8", children: _jsxs(Stack, { gap: "3", align: "center", className: "max-w-sm text-center", children: [_jsx(Heading, { level: 2, size: "h2", children: translate('components.detail.notFound.title') }), _jsx(Text, { tone: "muted", children: translate('components.detail.notFound.body', { id: componentId }) }), _jsx(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: studioPath.components() }, className: "text-sm text-primary hover:underline", children: translate('components.detail.notFound.back') })] }) }));
|
|
26
26
|
}
|
|
27
27
|
const status = componentStatus(studio, manifest);
|
|
28
|
-
const namespace = isPrimitive(manifest.id) ? 'base
|
|
28
|
+
const namespace = isPrimitive(manifest.id) ? 'base' : 'project';
|
|
29
29
|
return (_jsxs("div", { className: "mx-auto w-full max-w-4xl p-4 sm:p-6", children: [_jsxs(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: studioPath.components() }, className: "mb-3 inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground md:hidden", children: [_jsx(ArrowLeft, { className: "size-3.5" }), " ", translate('components.detail.allComponents')] }), _jsxs(Stack, { gap: "6", children: [_jsxs(Stack, { gap: "2", children: [_jsxs(Row, { gap: "2", wrap: true, children: [_jsx(Heading, { size: "display", children: manifest.title ?? manifest.id }), _jsx(Badge, { variant: namespace === 'project' ? 'primary' : 'outline', children: namespace }), manifest.category ? _jsx(Badge, { variant: "outline", children: manifest.category }) : null, _jsx(Spacer, {}), _jsx(ComponentStatusBadge, { status: status })] }), _jsx(Code, { tone: "muted", className: "w-fit", children: manifest.id }), manifest.description ? (_jsx(Text, { tone: "muted", className: "max-w-prose", children: manifest.description })) : null, status !== 'valid' ? (_jsx(Text, { size: "sm", className: status === 'invalid' ? 'text-destructive' : 'text-warning', children: statusDescription(status) })) : null] }), manifest.designTokens && manifest.designTokens.length > 0 ? (_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "xs", tone: "muted", mono: true, children: translate('components.detail.designTokens') }), _jsx(Row, { gap: "1", wrap: true, children: manifest.designTokens.map((token) => (_jsx(Code, { tone: "muted", children: token }, token))) })] })) : null, _jsx(FixtureRunner, { manifest: manifest }, manifest.id), _jsx(FieldInspector, { manifest: manifest }), _jsx(ReferencesPanel, { projectId: projectId, target: { type: 'component', id: manifest.id } })] })] }));
|
|
30
30
|
}
|
|
31
31
|
//# sourceMappingURL=component-detail.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"component-detail.js","sourceRoot":"","sources":["../../../src/components/components/component-detail.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAA;AAChE,OAAO,EAAE,IAAI,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAA;AACjF,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAA;AACnF,OAAO,EACL,oBAAoB,EACpB,iBAAiB,GAClB,MAAM,0DAA0D,CAAA;AACjE,OAAO,EAAE,cAAc,EAAE,MAAM,yDAAyD,CAAA;AACxF,OAAO,EAAE,aAAa,EAAE,MAAM,wDAAwD,CAAA;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,0DAA0D,CAAA;AAC1F,OAAO,EAAE,gBAAgB,EAAE,MAAM,iDAAiD,CAAA;AAClF,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAErF;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,EAC9B,SAAS,EACT,WAAW,GAIZ;IACC,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAA;IAClC,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,WAAW,CAAC,CAAA;IAEzD,4EAA4E;IAC5E,mDAAmD;IACnD,gBAAgB,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,IAAI,QAAQ,EAAE,EAAE,IAAI,WAAW,EAAE,CAAC,CAAC,CAAA;IAE7E,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,CACL,cAAK,SAAS,EAAC,oCAAoC,YACjD,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,EAAC,KAAK,EAAC,QAAQ,EAAC,SAAS,EAAC,sBAAsB,aAC5D,KAAC,OAAO,IAAC,KAAK,EAAE,CAAC,EAAE,IAAI,EAAC,IAAI,YACzB,SAAS,CAAC,kCAAkC,CAAC,GACtC,EACV,KAAC,IAAI,IAAC,IAAI,EAAC,OAAO,YACf,SAAS,CAAC,iCAAiC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,CAAC,GAC7D,EACP,KAAC,IAAI,IACH,EAAE,EAAE,oBAAoB,EACxB,MAAM,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,UAAU,EAAE,EAAE,EACtD,SAAS,EAAC,sCAAsC,YAE/C,SAAS,CAAC,iCAAiC,CAAC,GACxC,IACD,GACJ,CACP,CAAA;IACH,CAAC;IAED,MAAM,MAAM,GAAG,eAAe,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;IAChD,MAAM,SAAS,GAAG,WAAW,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"component-detail.js","sourceRoot":"","sources":["../../../src/components/components/component-detail.tsx"],"names":[],"mappings":"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","sourcesContent":["import { ArrowLeft } from 'lucide-react'\nimport { isPrimitive } from '@elytracms/core/component-registry'\nimport { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router'\nimport { componentStatus, getStudioRegistry } from '@elytracms/studio/lib/registry'\nimport {\n ComponentStatusBadge,\n statusDescription,\n} from '@elytracms/studio/components/components/component-status'\nimport { FieldInspector } from '@elytracms/studio/components/components/field-inspector'\nimport { FixtureRunner } from '@elytracms/studio/components/components/fixture-runner'\nimport { ReferencesPanel } from '@elytracms/studio/components/references/references-panel'\nimport { useSetBreadcrumb } from '@elytracms/studio/components/breadcrumb-context'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { Badge, Code, Heading, Row, Spacer, Stack, Text } from '@elytracms/studio/ui'\n\n/**\n * The component detail view (EC-223 Stage 6): manifest metadata, the field/slot\n * inspector, the fixture runner, and inbound references. Takes its ids as props\n * — the splat resolver sources `componentId` from the engine route table.\n */\nexport function ComponentDetail({\n projectId,\n componentId,\n}: {\n projectId: string\n componentId: string\n}) {\n const studio = getStudioRegistry()\n const manifest = studio.registry.getManifest(componentId)\n\n // EC-214: the component is the active record — name it in the global header\n // breadcrumb (under the Components section crumb).\n useSetBreadcrumb([{ label: manifest?.title ?? manifest?.id ?? componentId }])\n\n if (!manifest) {\n return (\n <div className=\"grid h-full place-items-center p-8\">\n <Stack gap=\"3\" align=\"center\" className=\"max-w-sm text-center\">\n <Heading level={2} size=\"h2\">\n {translate('components.detail.notFound.title')}\n </Heading>\n <Text tone=\"muted\">\n {translate('components.detail.notFound.body', { id: componentId })}\n </Text>\n <Link\n to={STUDIO_PROJECT_ROUTE}\n params={{ projectId, _splat: studioPath.components() }}\n className=\"text-sm text-primary hover:underline\"\n >\n {translate('components.detail.notFound.back')}\n </Link>\n </Stack>\n </div>\n )\n }\n\n const status = componentStatus(studio, manifest)\n const namespace = isPrimitive(manifest.id) ? 'base' : 'project'\n\n return (\n <div className=\"mx-auto w-full max-w-4xl p-4 sm:p-6\">\n <Link\n to={STUDIO_PROJECT_ROUTE}\n params={{ projectId, _splat: studioPath.components() }}\n className=\"mb-3 inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground md:hidden\"\n >\n <ArrowLeft className=\"size-3.5\" /> {translate('components.detail.allComponents')}\n </Link>\n\n <Stack gap=\"6\">\n {/* Header */}\n <Stack gap=\"2\">\n <Row gap=\"2\" wrap>\n <Heading size=\"display\">{manifest.title ?? manifest.id}</Heading>\n <Badge variant={namespace === 'project' ? 'primary' : 'outline'}>{namespace}</Badge>\n {manifest.category ? <Badge variant=\"outline\">{manifest.category}</Badge> : null}\n <Spacer />\n <ComponentStatusBadge status={status} />\n </Row>\n <Code tone=\"muted\" className=\"w-fit\">\n {manifest.id}\n </Code>\n {manifest.description ? (\n <Text tone=\"muted\" className=\"max-w-prose\">\n {manifest.description}\n </Text>\n ) : null}\n {status !== 'valid' ? (\n <Text size=\"sm\" className={status === 'invalid' ? 'text-destructive' : 'text-warning'}>\n {statusDescription(status)}\n </Text>\n ) : null}\n </Stack>\n\n {/* Design tokens */}\n {manifest.designTokens && manifest.designTokens.length > 0 ? (\n <Stack gap=\"1\">\n <Text size=\"xs\" tone=\"muted\" mono>\n {translate('components.detail.designTokens')}\n </Text>\n <Row gap=\"1\" wrap>\n {manifest.designTokens.map((token) => (\n <Code key={token} tone=\"muted\">\n {token}\n </Code>\n ))}\n </Row>\n </Stack>\n ) : null}\n\n {/* Fixture runner — keyed so state resets when switching components. */}\n <FixtureRunner key={manifest.id} manifest={manifest} />\n\n {/* Field inspector */}\n <FieldInspector manifest={manifest} />\n\n {/* Where is this component used? (EC-172, inbound over the EC-155 index) */}\n <ReferencesPanel projectId={projectId} target={{ type: 'component', id: manifest.id }} />\n </Stack>\n </div>\n )\n}\n"]}
|
|
@@ -31,7 +31,7 @@ export function ComponentsIndex({ projectId }) {
|
|
|
31
31
|
return (_jsxs("div", { className: "mx-auto w-full max-w-4xl p-4 sm:p-6", children: [_jsx("div", { className: "mb-6 h-80 overflow-hidden rounded-lg border border-border md:hidden", children: _jsx(RegistryBrowser, { projectId: projectId }) }), _jsxs(Stack, { gap: "6", children: [_jsxs(Stack, { gap: "1", children: [_jsx(Heading, { size: "display", children: translate('components.index.title') }), _jsx(Text, { tone: "muted", children: translate('components.index.subtitle') })] }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-3", children: [_jsx(StatCard, { label: translate('components.index.stat.total'), value: health.total, hint: translate('components.index.stat.totalHint', { primitives, projects }) }), _jsx(StatCard, { label: translate('components.index.stat.valid'), value: health.valid, tone: "success" }), _jsx(StatCard, { label: translate('components.index.stat.needAttention'), value: health.incompatible + health.invalid, tone: health.incompatible + health.invalid > 0 ? 'warning' : 'muted', hint: translate('components.index.stat.needAttentionHint', {
|
|
32
32
|
incompatible: health.incompatible,
|
|
33
33
|
invalid: health.invalid,
|
|
34
|
-
}) })] }), _jsxs(Card, { className: "flex items-center gap-3 p-4", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground", children: _jsx(MousePointerClick, { className: "size-4" }) }), _jsx(Text, { size: "sm", tone: "muted", children: translate('components.index.pickHint') })] }), _jsxs(Panel, { children: [_jsxs(PanelHeader, { children: [_jsx(Blocks, { className: "size-4 text-muted-foreground" }), _jsx(Text, { size: "sm", weight: "semibold", children: translate('components.index.guide.title') }), _jsx(Spacer, {}), _jsx(Badge, { variant: "outline", children: translate('components.index.guide.badge') })] }), _jsxs(Stack, { gap: "3", className: "p-4", children: [_jsxs(Text, { size: "sm", tone: "muted", children: ["Project code owns its components. Register a manifest under the", ' ', _jsx(Code, { tone: "primary", children: "project.*" }), " namespace (platform primitives live under", ' ', _jsx(Code, { tone: "muted", children: "base
|
|
34
|
+
}) })] }), _jsxs(Card, { className: "flex items-center gap-3 p-4", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground", children: _jsx(MousePointerClick, { className: "size-4" }) }), _jsx(Text, { size: "sm", tone: "muted", children: translate('components.index.pickHint') })] }), _jsxs(Panel, { children: [_jsxs(PanelHeader, { children: [_jsx(Blocks, { className: "size-4 text-muted-foreground" }), _jsx(Text, { size: "sm", weight: "semibold", children: translate('components.index.guide.title') }), _jsx(Spacer, {}), _jsx(Badge, { variant: "outline", children: translate('components.index.guide.badge') })] }), _jsxs(Stack, { gap: "3", className: "p-4", children: [_jsxs(Text, { size: "sm", tone: "muted", children: ["Project code owns its components. Register a manifest under the", ' ', _jsx(Code, { tone: "primary", children: "project.*" }), " namespace (platform primitives live under", ' ', _jsx(Code, { tone: "muted", children: "base.*" }), "). The manifest declares prop schemas, defaults, inspector controls, binding permissions, slots, and preview fixtures."] }), _jsx("pre", { className: "overflow-x-auto rounded-lg border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed text-foreground/90", children: REGISTER_EXAMPLE }), _jsx(Text, { size: "xs", tone: "subtle", children: translate('components.index.guide.fullGuide', { path: 'docs/component-authoring.md' }) })] })] })] })] }));
|
|
35
35
|
}
|
|
36
36
|
function StatCard({ label, value, hint, tone = 'default', }) {
|
|
37
37
|
const valueClass = tone === 'success'
|