@saena-io/create 0.1.0 → 0.2.0
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/index.js +9 -9
- package/package.json +1 -1
- package/template/base/package.json +44 -2
- package/template/base/scripts/ui-update.ts +83 -0
- package/template/base/src/components/ui/accordion.tsx +75 -0
- package/template/base/src/components/ui/alert-dialog.tsx +162 -0
- package/template/base/src/components/ui/alert.tsx +73 -0
- package/template/base/src/components/ui/app-sidebar.tsx +183 -0
- package/template/base/src/components/ui/aspect-ratio.tsx +22 -0
- package/template/base/src/components/ui/asset-input.tsx +211 -0
- package/template/base/src/components/ui/avatar.tsx +91 -0
- package/template/base/src/components/ui/badge.tsx +50 -0
- package/template/base/src/components/ui/breadcrumb.tsx +104 -0
- package/template/base/src/components/ui/button-group.tsx +78 -0
- package/template/base/src/components/ui/button.tsx +56 -0
- package/template/base/src/components/ui/calendar.tsx +205 -0
- package/template/base/src/components/ui/card.tsx +85 -0
- package/template/base/src/components/ui/carousel.tsx +232 -0
- package/template/base/src/components/ui/chart.tsx +337 -0
- package/template/base/src/components/ui/checkbox.tsx +29 -0
- package/template/base/src/components/ui/collapsible.tsx +15 -0
- package/template/base/src/components/ui/combobox.tsx +276 -0
- package/template/base/src/components/ui/command.tsx +190 -0
- package/template/base/src/components/ui/context-menu.tsx +243 -0
- package/template/base/src/components/ui/dialog.tsx +134 -0
- package/template/base/src/components/ui/direction.tsx +4 -0
- package/template/base/src/components/ui/drawer.tsx +120 -0
- package/template/base/src/components/ui/dropdown-menu.tsx +254 -0
- package/template/base/src/components/ui/empty.tsx +94 -0
- package/template/base/src/components/ui/field.tsx +222 -0
- package/template/base/src/components/ui/focal-point-picker.tsx +175 -0
- package/template/base/src/components/ui/hover-card.tsx +46 -0
- package/template/base/src/components/ui/input-group.tsx +149 -0
- package/template/base/src/components/ui/input-otp.tsx +85 -0
- package/template/base/src/components/ui/input.tsx +20 -0
- package/template/base/src/components/ui/item.tsx +188 -0
- package/template/base/src/components/ui/kbd.tsx +26 -0
- package/template/base/src/components/ui/label.tsx +20 -0
- package/template/base/src/components/ui/menubar.tsx +268 -0
- package/template/base/src/components/ui/native-select.tsx +58 -0
- package/template/base/src/components/ui/nav-main.tsx +70 -0
- package/template/base/src/components/ui/nav-projects.tsx +97 -0
- package/template/base/src/components/ui/nav-secondary.tsx +37 -0
- package/template/base/src/components/ui/nav-user.tsx +108 -0
- package/template/base/src/components/ui/navigation-menu.tsx +164 -0
- package/template/base/src/components/ui/pagination.tsx +123 -0
- package/template/base/src/components/ui/popover.tsx +80 -0
- package/template/base/src/components/ui/progress.tsx +66 -0
- package/template/base/src/components/ui/radio-group.tsx +36 -0
- package/template/base/src/components/ui/resizable.tsx +42 -0
- package/template/base/src/components/ui/rich-text/ai-chat-editor.tsx +20 -0
- package/template/base/src/components/ui/rich-text/ai-command.tsx +90 -0
- package/template/base/src/components/ui/rich-text/ai-copilot.tsx +67 -0
- package/template/base/src/components/ui/rich-text/ai-menu.tsx +456 -0
- package/template/base/src/components/ui/rich-text/ai-node.tsx +42 -0
- package/template/base/src/components/ui/rich-text/ai-toolbar-button.tsx +29 -0
- package/template/base/src/components/ui/rich-text/block-draggable.tsx +187 -0
- package/template/base/src/components/ui/rich-text/block-selection.tsx +17 -0
- package/template/base/src/components/ui/rich-text/code-block-node.tsx +204 -0
- package/template/base/src/components/ui/rich-text/codec.ts +63 -0
- package/template/base/src/components/ui/rich-text/extension.ts +53 -0
- package/template/base/src/components/ui/rich-text/ghost-text.tsx +23 -0
- package/template/base/src/components/ui/rich-text/import-export-toolbar.tsx +103 -0
- package/template/base/src/components/ui/rich-text/link.tsx +18 -0
- package/template/base/src/components/ui/rich-text/list-node.tsx +65 -0
- package/template/base/src/components/ui/rich-text/nodes.tsx +44 -0
- package/template/base/src/components/ui/rich-text/plugins.ts +233 -0
- package/template/base/src/components/ui/rich-text/rich-text-editor.tsx +82 -0
- package/template/base/src/components/ui/rich-text/static.tsx +117 -0
- package/template/base/src/components/ui/rich-text/table-node.tsx +934 -0
- package/template/base/src/components/ui/rich-text/table-toolbar.tsx +232 -0
- package/template/base/src/components/ui/rich-text/toggle-node.tsx +36 -0
- package/template/base/src/components/ui/rich-text/toolbar-slots.ts +41 -0
- package/template/base/src/components/ui/rich-text/toolbar.tsx +668 -0
- package/template/base/src/components/ui/rich-text/use-ai-chat.ts +35 -0
- package/template/base/src/components/ui/rich-text/variable-type.ts +4 -0
- package/template/base/src/components/ui/rich-text/variable.tsx +97 -0
- package/template/base/src/components/ui/scroll-area.tsx +49 -0
- package/template/base/src/components/ui/select.tsx +202 -0
- package/template/base/src/components/ui/separator.tsx +19 -0
- package/template/base/src/components/ui/sheet.tsx +126 -0
- package/template/base/src/components/ui/sidebar.tsx +695 -0
- package/template/base/src/components/ui/skeleton.tsx +13 -0
- package/template/base/src/components/ui/slider.tsx +52 -0
- package/template/base/src/components/ui/sonner.tsx +50 -0
- package/template/base/src/components/ui/spinner.tsx +18 -0
- package/template/base/src/components/ui/switch.tsx +30 -0
- package/template/base/src/components/ui/table.tsx +89 -0
- package/template/base/src/components/ui/tabs.tsx +73 -0
- package/template/base/src/components/ui/textarea.tsx +18 -0
- package/template/base/src/components/ui/toggle-group.tsx +85 -0
- package/template/base/src/components/ui/toggle.tsx +45 -0
- package/template/base/src/components/ui/toolbar.tsx +451 -0
- package/template/base/src/components/ui/tooltip.tsx +52 -0
- package/template/base/src/hooks/use-mobile.ts +19 -0
- package/template/base/src/lib/utils.ts +6 -0
- package/template/base/src/routes/__root.tsx +1 -1
- package/template/base/src/server/auth.ts +2 -2
- package/template/base/src/styles/globals.css +230 -0
- package/template/base/vite.config.ts +15 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isOrderedList } from '@platejs/list';
|
|
2
|
+
import { useTodoListElement, useTodoListElementState } from '@platejs/list/react';
|
|
3
|
+
import { Checkbox } from '@saena-io/ui/components/checkbox';
|
|
4
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
5
|
+
import { type PlateElementProps, type RenderNodeWrapper, useReadOnly } from 'platejs/react';
|
|
6
|
+
import type { FC } from 'react';
|
|
7
|
+
|
|
8
|
+
// Custom list rendering that adds a TODO variant. The base list plugin draws bullets/numbers via CSS
|
|
9
|
+
// `list-style-type` on a <ul>/<ol><li>; CSS can't draw a checkbox, so todo items (listStyleType 'todo')
|
|
10
|
+
// render a Checkbox marker + a strikethrough-when-checked <li> instead. Registered as ListPlugin's
|
|
11
|
+
// render.belowNodes (see rich-text-editor.tsx) — for non-todo lists it falls back to the same ul/ol markup.
|
|
12
|
+
|
|
13
|
+
type ListElement = PlateElementProps['element'] & {
|
|
14
|
+
listStart?: number;
|
|
15
|
+
listStyleType?: string;
|
|
16
|
+
checked?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TODO_CONFIG: Record<string, { Li: FC<PlateElementProps>; Marker: FC<PlateElementProps> }> = {
|
|
20
|
+
todo: { Li: TodoLi, Marker: TodoMarker },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const BlockList: RenderNodeWrapper = (props) => {
|
|
24
|
+
if (!(props.element as ListElement).listStyleType) return;
|
|
25
|
+
return (childProps) => <List {...childProps} />;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function List(props: PlateElementProps) {
|
|
29
|
+
const element = props.element as ListElement;
|
|
30
|
+
const { Li, Marker } = TODO_CONFIG[element.listStyleType ?? ''] ?? {};
|
|
31
|
+
const ListTag = isOrderedList(props.element) ? 'ol' : 'ul';
|
|
32
|
+
return (
|
|
33
|
+
<ListTag
|
|
34
|
+
className="relative m-0 p-0"
|
|
35
|
+
style={{ listStyleType: element.listStyleType }}
|
|
36
|
+
start={element.listStart}
|
|
37
|
+
>
|
|
38
|
+
{Marker ? <Marker {...props} /> : null}
|
|
39
|
+
{Li ? <Li {...props} /> : <li>{props.children}</li>}
|
|
40
|
+
</ListTag>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function TodoMarker(props: PlateElementProps) {
|
|
45
|
+
const state = useTodoListElementState({ element: props.element });
|
|
46
|
+
const { checkboxProps } = useTodoListElement(state);
|
|
47
|
+
const readOnly = useReadOnly();
|
|
48
|
+
return (
|
|
49
|
+
<div contentEditable={false}>
|
|
50
|
+
<Checkbox
|
|
51
|
+
className={cn('-left-6 absolute top-0.5', readOnly && 'pointer-events-none')}
|
|
52
|
+
{...checkboxProps}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function TodoLi(props: PlateElementProps) {
|
|
59
|
+
const checked = (props.element as ListElement).checked;
|
|
60
|
+
return (
|
|
61
|
+
<li className={cn('list-none', checked && 'text-muted-foreground line-through')}>
|
|
62
|
+
{props.children}
|
|
63
|
+
</li>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PlateElement, type PlateElementProps } from 'platejs/react';
|
|
2
|
+
|
|
3
|
+
// Node renderers for the rich-text editor, written on plain elements (no Radix) so they fit our
|
|
4
|
+
// Base-UI design system. Styling is Tailwind; PlateElement applies the editor attributes + children.
|
|
5
|
+
|
|
6
|
+
export function H1Element(props: PlateElementProps) {
|
|
7
|
+
return (
|
|
8
|
+
<PlateElement
|
|
9
|
+
as="h1"
|
|
10
|
+
className="mt-[1.6em] mb-1 pb-1 font-bold font-heading text-4xl"
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function H2Element(props: PlateElementProps) {
|
|
17
|
+
return (
|
|
18
|
+
<PlateElement
|
|
19
|
+
as="h2"
|
|
20
|
+
className="mt-[1.4em] mb-1 pb-px font-heading font-semibold text-2xl tracking-tight"
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function H3Element(props: PlateElementProps) {
|
|
27
|
+
return (
|
|
28
|
+
<PlateElement
|
|
29
|
+
as="h3"
|
|
30
|
+
className="mt-[1em] mb-1 pb-px font-heading font-semibold text-xl tracking-tight"
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function BlockquoteElement(props: PlateElementProps) {
|
|
37
|
+
return (
|
|
38
|
+
<PlateElement
|
|
39
|
+
as="blockquote"
|
|
40
|
+
className="my-2 border-muted-foreground/30 border-l-2 pl-3 text-muted-foreground italic"
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BlockquoteRules,
|
|
3
|
+
BoldRules,
|
|
4
|
+
CodeRules,
|
|
5
|
+
HeadingRules,
|
|
6
|
+
ItalicRules,
|
|
7
|
+
MarkComboRules,
|
|
8
|
+
StrikethroughRules,
|
|
9
|
+
UnderlineRules,
|
|
10
|
+
} from '@platejs/basic-nodes';
|
|
11
|
+
import {
|
|
12
|
+
BlockquotePlugin,
|
|
13
|
+
BoldPlugin,
|
|
14
|
+
CodePlugin,
|
|
15
|
+
H1Plugin,
|
|
16
|
+
H2Plugin,
|
|
17
|
+
H3Plugin,
|
|
18
|
+
ItalicPlugin,
|
|
19
|
+
StrikethroughPlugin,
|
|
20
|
+
UnderlinePlugin,
|
|
21
|
+
} from '@platejs/basic-nodes/react';
|
|
22
|
+
import { TextAlignPlugin } from '@platejs/basic-styles/react';
|
|
23
|
+
import { CodeBlockRules } from '@platejs/code-block';
|
|
24
|
+
import { CodeBlockPlugin, CodeLinePlugin, CodeSyntaxPlugin } from '@platejs/code-block/react';
|
|
25
|
+
import { DndPlugin } from '@platejs/dnd';
|
|
26
|
+
import { DocxPlugin } from '@platejs/docx';
|
|
27
|
+
import { IndentPlugin } from '@platejs/indent/react';
|
|
28
|
+
import { JuicePlugin } from '@platejs/juice';
|
|
29
|
+
import { LinkRules } from '@platejs/link';
|
|
30
|
+
import { LinkPlugin } from '@platejs/link/react';
|
|
31
|
+
import { BulletedListRules, OrderedListRules, TaskListRules } from '@platejs/list';
|
|
32
|
+
import { ListPlugin } from '@platejs/list/react';
|
|
33
|
+
import { MarkdownPlugin } from '@platejs/markdown';
|
|
34
|
+
import { BlockSelectionPlugin } from '@platejs/selection/react';
|
|
35
|
+
import {
|
|
36
|
+
TableCellHeaderPlugin,
|
|
37
|
+
TableCellPlugin,
|
|
38
|
+
TablePlugin,
|
|
39
|
+
TableRowPlugin,
|
|
40
|
+
} from '@platejs/table/react';
|
|
41
|
+
import { TogglePlugin } from '@platejs/toggle/react';
|
|
42
|
+
import { common, createLowlight } from 'lowlight';
|
|
43
|
+
import { ExitBreakPlugin, KEYS, TrailingBlockPlugin, type Value } from 'platejs';
|
|
44
|
+
import { usePlateEditor } from 'platejs/react';
|
|
45
|
+
import remarkGfm from 'remark-gfm';
|
|
46
|
+
import { BlockDraggable } from './block-draggable';
|
|
47
|
+
import { CodeBlockElement, CodeSyntaxLeaf } from './code-block-node';
|
|
48
|
+
import { emptyRichValue } from './codec';
|
|
49
|
+
import type { RichTextExtension } from './extension';
|
|
50
|
+
import { LinkElement } from './link';
|
|
51
|
+
import { BlockList } from './list-node';
|
|
52
|
+
import { BlockquoteElement, H1Element, H2Element, H3Element } from './nodes';
|
|
53
|
+
import {
|
|
54
|
+
TableCellElement,
|
|
55
|
+
TableCellHeaderElement,
|
|
56
|
+
TableElement,
|
|
57
|
+
TableRowElement,
|
|
58
|
+
} from './table-node';
|
|
59
|
+
import { ToggleElement } from './toggle-node';
|
|
60
|
+
import { VariablePlugin } from './variable';
|
|
61
|
+
|
|
62
|
+
// The editor's Plate plugin set + the editor factory. This module is the toolbar-free LEAF of the rich-text
|
|
63
|
+
// dependency graph: it imports only node renderers and platejs, never the toolbar. That's what lets the
|
|
64
|
+
// toolbar (and table-toolbar / import-export-toolbar) import the `RichTextEditor` type from here without a
|
|
65
|
+
// cycle — the toolbar is mounted by RichTextContent (see rich-text-editor.tsx), not registered as a plugin.
|
|
66
|
+
|
|
67
|
+
// Shared lowlight instance (highlight.js `common` language set) that powers code-block syntax highlighting.
|
|
68
|
+
// Created once at module scope and handed to CodeBlockPlugin.options.lowlight; the plugin's decorate emits
|
|
69
|
+
// `code_syntax` leaves carrying highlight.js class names that CodeSyntaxLeaf + globals.css (.saena-hljs) style.
|
|
70
|
+
const lowlight = createLowlight(common);
|
|
71
|
+
|
|
72
|
+
// The core rich-text capability set (§9, ADR-0005). This array IS the extension seam: future tool
|
|
73
|
+
// plugins (media, AI/copilot, tables, …) append their Plate plugins here, and matching toolbar groups
|
|
74
|
+
// to the toolbar. Built on our Base-UI primitives — not the Radix shadcn block.
|
|
75
|
+
export const coreRichTextPlugins = [
|
|
76
|
+
// Inline marks. Each carries markdown autoformat input rules (**bold**, _italic_, `code`, ~~strike~~)
|
|
77
|
+
// plus the keyboard shortcuts the bare plugins miss — strikethrough mod+shift+x and code mod+e (bold /
|
|
78
|
+
// italic / underline already ship their own mod+b/i/u).
|
|
79
|
+
BoldPlugin.configure({
|
|
80
|
+
inputRules: [
|
|
81
|
+
BoldRules.markdown({ variant: '*' }),
|
|
82
|
+
BoldRules.markdown({ variant: '_' }),
|
|
83
|
+
MarkComboRules.markdown({ variant: 'boldItalic' }),
|
|
84
|
+
MarkComboRules.markdown({ variant: 'boldUnderline' }),
|
|
85
|
+
MarkComboRules.markdown({ variant: 'boldItalicUnderline' }),
|
|
86
|
+
MarkComboRules.markdown({ variant: 'italicUnderline' }),
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
ItalicPlugin.configure({
|
|
90
|
+
inputRules: [ItalicRules.markdown({ variant: '*' }), ItalicRules.markdown({ variant: '_' })],
|
|
91
|
+
}),
|
|
92
|
+
UnderlinePlugin.configure({ inputRules: [UnderlineRules.markdown()] }),
|
|
93
|
+
StrikethroughPlugin.configure({
|
|
94
|
+
inputRules: [StrikethroughRules.markdown()],
|
|
95
|
+
shortcuts: { toggle: { keys: 'mod+shift+x' } },
|
|
96
|
+
}),
|
|
97
|
+
CodePlugin.configure({
|
|
98
|
+
inputRules: [CodeRules.markdown()],
|
|
99
|
+
shortcuts: { toggle: { keys: 'mod+e' } },
|
|
100
|
+
}),
|
|
101
|
+
// Headings + quote. Markdown autoformat (`# `, `## `, `### `, `> `) and a reset-on-Enter rule so Enter
|
|
102
|
+
// in an empty heading drops back to a paragraph. .withComponent is folded into node.component because a
|
|
103
|
+
// plugin can't both .configure and .withComponent.
|
|
104
|
+
H1Plugin.configure({
|
|
105
|
+
inputRules: [HeadingRules.markdown()],
|
|
106
|
+
node: { component: H1Element },
|
|
107
|
+
rules: { break: { empty: 'reset' } },
|
|
108
|
+
}),
|
|
109
|
+
H2Plugin.configure({
|
|
110
|
+
inputRules: [HeadingRules.markdown()],
|
|
111
|
+
node: { component: H2Element },
|
|
112
|
+
rules: { break: { empty: 'reset' } },
|
|
113
|
+
}),
|
|
114
|
+
H3Plugin.configure({
|
|
115
|
+
inputRules: [HeadingRules.markdown()],
|
|
116
|
+
node: { component: H3Element },
|
|
117
|
+
rules: { break: { empty: 'reset' } },
|
|
118
|
+
}),
|
|
119
|
+
BlockquotePlugin.configure({
|
|
120
|
+
inputRules: [BlockquoteRules.markdown()],
|
|
121
|
+
node: { component: BlockquoteElement },
|
|
122
|
+
}),
|
|
123
|
+
// Multi-line code block (for larger snippets) — distinct from the inline `code` mark. Typing ``` or
|
|
124
|
+
// pasting a fenced block creates one. `lowlight` enables highlight.js syntax highlighting; the in-block
|
|
125
|
+
// language picker (CodeBlockElement) sets `element.lang`, which drives the decorate-based coloring.
|
|
126
|
+
CodeBlockPlugin.configure({
|
|
127
|
+
inputRules: [CodeBlockRules.markdown({ on: 'match' })],
|
|
128
|
+
node: { component: CodeBlockElement },
|
|
129
|
+
options: { lowlight, defaultLanguage: null },
|
|
130
|
+
shortcuts: { toggle: { keys: 'mod+alt+8' } },
|
|
131
|
+
}),
|
|
132
|
+
CodeLinePlugin,
|
|
133
|
+
CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf),
|
|
134
|
+
// Block alignment — stores `align` on the block and injects it as the `textAlign` CSS style. Targets
|
|
135
|
+
// the block types we render (headings + paragraphs); driven by the toolbar's align dropdown.
|
|
136
|
+
TextAlignPlugin.configure({
|
|
137
|
+
inject: {
|
|
138
|
+
nodeProps: {
|
|
139
|
+
defaultNodeValue: 'start',
|
|
140
|
+
nodeKey: 'align',
|
|
141
|
+
styleKey: 'textAlign',
|
|
142
|
+
validNodeValues: ['start', 'left', 'center', 'right', 'end', 'justify'],
|
|
143
|
+
},
|
|
144
|
+
targetPlugins: [...KEYS.heading, KEYS.p],
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
// Inline links — add/edit/remove via the toolbar's Link popover, plus autolink on paste/space/break and
|
|
148
|
+
// `[text](url)` markdown. LinkElement spreads getLinkAttributes for a real href.
|
|
149
|
+
LinkPlugin.configure({
|
|
150
|
+
inputRules: [
|
|
151
|
+
LinkRules.markdown(),
|
|
152
|
+
LinkRules.autolink({ variant: 'paste' }),
|
|
153
|
+
LinkRules.autolink({ variant: 'space' }),
|
|
154
|
+
LinkRules.autolink({ variant: 'break' }),
|
|
155
|
+
],
|
|
156
|
+
render: { node: LinkElement },
|
|
157
|
+
}),
|
|
158
|
+
// Indented lists (bulleted / numbered) — ListPlugin builds on IndentPlugin's block indentation. Both
|
|
159
|
+
// target headings + quote + paragraph so those blocks can be indented / turned into list items, and the
|
|
160
|
+
// list gains markdown autoformat (`- `, `* `, `1. `, `1) `).
|
|
161
|
+
IndentPlugin.configure({
|
|
162
|
+
inject: { targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote] },
|
|
163
|
+
options: { offset: 24 },
|
|
164
|
+
}),
|
|
165
|
+
ListPlugin.configure({
|
|
166
|
+
inputRules: [
|
|
167
|
+
BulletedListRules.markdown({ variant: '-' }),
|
|
168
|
+
BulletedListRules.markdown({ variant: '*' }),
|
|
169
|
+
OrderedListRules.markdown({ variant: '.' }),
|
|
170
|
+
OrderedListRules.markdown({ variant: ')' }),
|
|
171
|
+
// `- [ ] ` / `- [x] ` autoformat into a to-do list item.
|
|
172
|
+
TaskListRules.markdown({ checked: false }),
|
|
173
|
+
TaskListRules.markdown({ checked: true }),
|
|
174
|
+
],
|
|
175
|
+
inject: { targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote] },
|
|
176
|
+
// Override the default ul/ol marker render so to-do list items can draw a checkbox (CSS markers can't).
|
|
177
|
+
render: { belowNodes: BlockList },
|
|
178
|
+
}),
|
|
179
|
+
// Collapsible toggle blocks — a disclosure chevron (ToggleElement) that hides the blocks indented beneath
|
|
180
|
+
// it. Builds on IndentPlugin (registered above); the plugin tracks open/closed state per block id.
|
|
181
|
+
TogglePlugin.withComponent(ToggleElement),
|
|
182
|
+
// Block selection + drag, required by the table renderers: cell-selection overlays read from
|
|
183
|
+
// BlockSelectionPlugin and the row drag handle uses DndPlugin (needs a single react-dnd backend at the
|
|
184
|
+
// editor root — see RichTextDndProvider). Registered before the table so its transforms are available.
|
|
185
|
+
BlockSelectionPlugin,
|
|
186
|
+
// Per-block drag handle (grip on hover) — render.aboveNodes wraps every block with BlockDraggable.
|
|
187
|
+
DndPlugin.configure({ render: { aboveNodes: BlockDraggable } }),
|
|
188
|
+
// Tables — insert + edit a bordered grid with column/row resize, row drag, and cell selection.
|
|
189
|
+
TablePlugin.withComponent(TableElement),
|
|
190
|
+
TableRowPlugin.withComponent(TableRowElement),
|
|
191
|
+
TableCellPlugin.withComponent(TableCellElement),
|
|
192
|
+
TableCellHeaderPlugin.withComponent(TableCellHeaderElement),
|
|
193
|
+
// Editing-feel behavior: Cmd+Enter escapes a nested block (table cell / quote) to a sibling paragraph,
|
|
194
|
+
// and a trailing empty paragraph is always kept after a terminal block (table / quote) so the caret can
|
|
195
|
+
// exit to normal text at the end of the document.
|
|
196
|
+
ExitBreakPlugin.configure({
|
|
197
|
+
shortcuts: { insert: { keys: 'mod+enter' }, insertBefore: { keys: 'mod+shift+enter' } },
|
|
198
|
+
}),
|
|
199
|
+
TrailingBlockPlugin,
|
|
200
|
+
// Document interop: Markdown serialize/deserialize (GFM tables, strikethrough, autolinks) powers
|
|
201
|
+
// import/export and Markdown paste; DocxPlugin + JuicePlugin let content paste in cleanly from Word.
|
|
202
|
+
MarkdownPlugin.configure({ options: { remarkPlugins: [remarkGfm] } }),
|
|
203
|
+
DocxPlugin,
|
|
204
|
+
JuicePlugin,
|
|
205
|
+
// i18n variables render as inline badges and round-trip through the stored Slate JSON.
|
|
206
|
+
VariablePlugin,
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
/** Options for a rich-text editor instance. */
|
|
210
|
+
export interface RichTextOptions {
|
|
211
|
+
/** Required when several editors share a PlateController (e.g. the source + target panes). */
|
|
212
|
+
id?: string;
|
|
213
|
+
value?: Value;
|
|
214
|
+
readOnly?: boolean;
|
|
215
|
+
/** Addons contributing Plate plugins + toolbar controls (media, text styles, AI, …). Read once when the
|
|
216
|
+
* editor is constructed (like usePlateEditor's plugins) — to change the addon set, remount the editor via
|
|
217
|
+
* a React key, not by mutating this array on a live instance. */
|
|
218
|
+
extensions?: RichTextExtension[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Create a rich-text editor instance — the core capability set plus any extension plugins. */
|
|
222
|
+
export function useRichTextEditor(opts: RichTextOptions) {
|
|
223
|
+
const extensionPlugins = (opts.extensions ?? []).flatMap((e) => e.platePlugins ?? []);
|
|
224
|
+
return usePlateEditor({
|
|
225
|
+
id: opts.id,
|
|
226
|
+
plugins: [...coreRichTextPlugins, ...extensionPlugins],
|
|
227
|
+
value: opts.value ?? emptyRichValue,
|
|
228
|
+
readOnly: opts.readOnly,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The concrete editor type (with the core plugins' transforms) — used to type toolbar handlers. */
|
|
233
|
+
export type RichTextEditor = ReturnType<typeof useRichTextEditor>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
2
|
+
import { PlateContent } from 'platejs/react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { DndProvider } from 'react-dnd';
|
|
5
|
+
import { HTML5Backend } from 'react-dnd-html5-backend';
|
|
6
|
+
import { coreRichTextPlugins, useRichTextEditor } from './plugins';
|
|
7
|
+
import { FloatingRichTextToolbar } from './toolbar';
|
|
8
|
+
|
|
9
|
+
// The rich-text editor's public surface. The Plate plugin set + editor factory live in the toolbar-free
|
|
10
|
+
// leaf ./plugins (so the toolbar can import the RichTextEditor type without a dependency cycle); this module
|
|
11
|
+
// composes them with the toolbar (FloatingRichTextToolbar, RichTextToolbar), the stored-value codec, and the
|
|
12
|
+
// content/DnD surfaces, and re-exports everything a consumer or addon author needs from @saena-io/ui.
|
|
13
|
+
|
|
14
|
+
export { variableNode, flattenRichText, extractPlaceholders } from './variable';
|
|
15
|
+
export { RichTextToolbar } from './toolbar';
|
|
16
|
+
export { coreRichTextPlugins, useRichTextEditor };
|
|
17
|
+
export { createAiCopilotExtension } from './ai-copilot';
|
|
18
|
+
export type { AiCopilotOptions } from './ai-copilot';
|
|
19
|
+
export { createAiCommandExtension } from './ai-command';
|
|
20
|
+
export type { AiCommandOptions } from './ai-command';
|
|
21
|
+
export { GhostText } from './ghost-text';
|
|
22
|
+
export { emptyRichValue, parseRichText, serializeRichText, richValueIsDirty } from './codec';
|
|
23
|
+
export type { RichTextEditor, RichTextOptions } from './plugins';
|
|
24
|
+
// Re-export the Plate surface consumers (and addon authors) need, so they go through @saena-io/ui (platejs
|
|
25
|
+
// stays an implementation detail of the design system) rather than depending on platejs directly — the
|
|
26
|
+
// dependency boundary that lets a feature package extend the editor without importing platejs (§13).
|
|
27
|
+
export { Plate, PlateController } from 'platejs/react';
|
|
28
|
+
export type { Value } from 'platejs';
|
|
29
|
+
export type { AnyPlatePlugin } from 'platejs/react';
|
|
30
|
+
export type { RichTextExtension, ToolbarItem, ToolbarSlot } from './extension';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The editable content surface. Place inside a <Plate editor={…}>. Renders the floating selection toolbar
|
|
34
|
+
* alongside the editable — it mounts here (rather than as a core plugin) so the plugin set in ./plugins
|
|
35
|
+
* stays toolbar-free; being inside <Plate>, its hooks still bind to this concrete editor (safe under the
|
|
36
|
+
* dual-pane PlateController).
|
|
37
|
+
*/
|
|
38
|
+
export function RichTextContent({
|
|
39
|
+
placeholder,
|
|
40
|
+
className,
|
|
41
|
+
lang,
|
|
42
|
+
}: {
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
className?: string;
|
|
45
|
+
/** BCP-47 language tag set on the editable so the browser spellchecks against the right dictionary
|
|
46
|
+
* (e.g. the translation pane gets the target locale, not the UI language). */
|
|
47
|
+
lang?: string;
|
|
48
|
+
}) {
|
|
49
|
+
return (
|
|
50
|
+
<>
|
|
51
|
+
<PlateContent
|
|
52
|
+
placeholder={placeholder}
|
|
53
|
+
lang={lang}
|
|
54
|
+
className={cn(
|
|
55
|
+
// Borderless, document-style surface — the host centers it in a max-width column. Grows with
|
|
56
|
+
// content (no inner scroll) so the surrounding area provides a single scroll.
|
|
57
|
+
'min-h-32 w-full px-1 py-2 text-sm leading-relaxed outline-none',
|
|
58
|
+
'[&_p]:my-1.5',
|
|
59
|
+
// The first block keeps its element margin (headings carry a large top margin for section
|
|
60
|
+
// separation) — zero it so content doesn't start far down. Blocks are wrapped by the drag handle
|
|
61
|
+
// (.flow-root holds the real element), so reach through it.
|
|
62
|
+
'[&>:first-child_.flow-root>:first-child]:mt-0 [&>:first-child:not(:has(.flow-root))]:mt-0',
|
|
63
|
+
// Indent-list markers render `outside` by default; render them `inside` so bullets/numbers stay
|
|
64
|
+
// within the text column.
|
|
65
|
+
'[&_ul]:[list-style-position:inside] [&_ol]:[list-style-position:inside] [&_li]:my-0.5',
|
|
66
|
+
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
|
|
67
|
+
className,
|
|
68
|
+
)}
|
|
69
|
+
/>
|
|
70
|
+
<FloatingRichTextToolbar />
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A single react-dnd backend for the table row-drag handles. Wrap it ONCE around all Plate editors that
|
|
77
|
+
* share a PlateController (the source + target panes) — two HTML5Backends mounted on the same page throw,
|
|
78
|
+
* so we can't register the provider per-editor via DndPlugin's render.
|
|
79
|
+
*/
|
|
80
|
+
export function RichTextDndProvider({ children }: { children: ReactNode }) {
|
|
81
|
+
return <DndProvider backend={HTML5Backend}>{children}</DndProvider>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseBlockquotePlugin,
|
|
3
|
+
BaseBoldPlugin,
|
|
4
|
+
BaseCodePlugin,
|
|
5
|
+
BaseHeadingPlugin,
|
|
6
|
+
BaseItalicPlugin,
|
|
7
|
+
BaseStrikethroughPlugin,
|
|
8
|
+
BaseUnderlinePlugin,
|
|
9
|
+
} from '@platejs/basic-nodes';
|
|
10
|
+
import { BaseCodeBlockPlugin, BaseCodeLinePlugin } from '@platejs/code-block';
|
|
11
|
+
import { BaseLinkPlugin } from '@platejs/link';
|
|
12
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
13
|
+
import { KEYS, type Value, createSlatePlugin } from 'platejs';
|
|
14
|
+
import {
|
|
15
|
+
PlateStatic,
|
|
16
|
+
SlateElement,
|
|
17
|
+
type SlateElementProps,
|
|
18
|
+
SlateLeaf,
|
|
19
|
+
type SlateLeafProps,
|
|
20
|
+
createStaticEditor,
|
|
21
|
+
} from 'platejs/static';
|
|
22
|
+
import { parseRichText } from './codec';
|
|
23
|
+
import { VARIABLE_TYPE } from './variable-type';
|
|
24
|
+
|
|
25
|
+
// The deferred ADR-0005 public-site renderer: render a stored rich `Value` to HTML with NO editor in the
|
|
26
|
+
// bundle. It uses Plate's BASE (React-free) plugins for the node schema + static SlateElement/SlateLeaf
|
|
27
|
+
// components — never the editor's `/react` plugins (those call Plate hooks and need a live <Plate>, which
|
|
28
|
+
// is exactly what the public site must avoid). CMS public pages are the first consumer (Prose renders here).
|
|
29
|
+
//
|
|
30
|
+
// v1 covers the Prose node set (paragraphs [a built-in node], headings, quote, marks, link, code, the i18n
|
|
31
|
+
// variable badge). Indent-based lists and tables get PlateStatic's defaults for now; their bespoke static
|
|
32
|
+
// components are an additive follow-up — the base-kit architecture means adding them never forces a rewrite.
|
|
33
|
+
|
|
34
|
+
// The i18n variable node, defined React-free so the static editor knows it's an inline void without pulling
|
|
35
|
+
// the editor's VariablePlugin (which imports platejs/react).
|
|
36
|
+
const baseVariablePlugin = createSlatePlugin({
|
|
37
|
+
key: VARIABLE_TYPE,
|
|
38
|
+
node: { type: VARIABLE_TYPE, isElement: true, isInline: true, isVoid: true },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const staticPlugins = [
|
|
42
|
+
BaseBoldPlugin,
|
|
43
|
+
BaseItalicPlugin,
|
|
44
|
+
BaseUnderlinePlugin,
|
|
45
|
+
BaseStrikethroughPlugin,
|
|
46
|
+
BaseCodePlugin,
|
|
47
|
+
BaseHeadingPlugin,
|
|
48
|
+
BaseBlockquotePlugin,
|
|
49
|
+
BaseLinkPlugin,
|
|
50
|
+
BaseCodeBlockPlugin,
|
|
51
|
+
BaseCodeLinePlugin,
|
|
52
|
+
baseVariablePlugin,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// `as` is the HTML tag the node renders to (SlateElement/SlateLeaf accept an intrinsic tag name); all our
|
|
56
|
+
// node mappings below pass a plain tag string, so type it as such rather than the broad ElementType.
|
|
57
|
+
type Tag = keyof HTMLElementTagNameMap;
|
|
58
|
+
function El(as: Tag, className?: string) {
|
|
59
|
+
return (props: SlateElementProps) => <SlateElement as={as} className={className} {...props} />;
|
|
60
|
+
}
|
|
61
|
+
function Leaf(as: Tag, className?: string) {
|
|
62
|
+
return (props: SlateLeafProps) => <SlateLeaf as={as} className={className} {...props} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The i18n variable as a static inline badge (mirrors the editor's VariableElement, no selection state). */
|
|
66
|
+
function VariableStatic(props: SlateElementProps) {
|
|
67
|
+
const name = (props.element as { name?: string }).name ?? '';
|
|
68
|
+
return (
|
|
69
|
+
<SlateElement as="span" {...props}>
|
|
70
|
+
<span className="mx-0.5 inline-flex select-none items-center rounded bg-primary px-1.5 py-px font-medium font-mono text-[0.8em] text-primary-foreground">
|
|
71
|
+
{name}
|
|
72
|
+
</span>
|
|
73
|
+
{props.children}
|
|
74
|
+
</SlateElement>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const staticComponents = {
|
|
79
|
+
[KEYS.p]: El('p', 'my-1.5 leading-relaxed'),
|
|
80
|
+
[KEYS.h1]: El('h1', 'mt-8 mb-3 font-bold text-3xl'),
|
|
81
|
+
[KEYS.h2]: El('h2', 'mt-6 mb-2 font-semibold text-2xl'),
|
|
82
|
+
[KEYS.h3]: El('h3', 'mt-4 mb-2 font-semibold text-xl'),
|
|
83
|
+
[KEYS.blockquote]: El('blockquote', 'my-3 border-muted-foreground/30 border-l-2 pl-4 italic'),
|
|
84
|
+
[KEYS.codeBlock]: El('pre', 'my-3 overflow-auto rounded bg-muted p-3 font-mono text-sm'),
|
|
85
|
+
[KEYS.codeLine]: El('div'),
|
|
86
|
+
[KEYS.a]: El('a', 'font-medium text-primary underline underline-offset-2'),
|
|
87
|
+
[VARIABLE_TYPE]: VariableStatic,
|
|
88
|
+
[KEYS.bold]: Leaf('strong'),
|
|
89
|
+
[KEYS.italic]: Leaf('em'),
|
|
90
|
+
[KEYS.underline]: Leaf('u'),
|
|
91
|
+
[KEYS.strikethrough]: Leaf('s'),
|
|
92
|
+
[KEYS.code]: Leaf('code', 'rounded bg-muted px-1 py-0.5 font-mono text-xs'),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Render a stored rich value for the public site — editor-free (ADR-0005). Safe in a Server Component.
|
|
97
|
+
*
|
|
98
|
+
* Accepts either an already-parsed `Value` or the raw stored string (serialized Slate JSON, the shape the
|
|
99
|
+
* codec persists); a string is parsed here with the plain-text fallback so a legacy plain row still renders.
|
|
100
|
+
*/
|
|
101
|
+
export function RichTextStatic({
|
|
102
|
+
value,
|
|
103
|
+
className,
|
|
104
|
+
}: { value: Value | string; className?: string }) {
|
|
105
|
+
const doc = typeof value === 'string' ? parseRichText(value, { plainTextFallback: true }) : value;
|
|
106
|
+
const editor = createStaticEditor({
|
|
107
|
+
plugins: staticPlugins,
|
|
108
|
+
value: doc,
|
|
109
|
+
components: staticComponents,
|
|
110
|
+
// Disable Plate's NodeId plugin for the static render. By default it assigns each block a fresh
|
|
111
|
+
// `nanoid(10)` id during editor init (→ `data-block-id` / `data-slate-id`), which runs independently on
|
|
112
|
+
// the server and on the client, producing different ids and an SSR hydration mismatch. A read-only public
|
|
113
|
+
// render needs no node ids (no selection / DnD), so turning it off makes the markup deterministic.
|
|
114
|
+
nodeId: false,
|
|
115
|
+
});
|
|
116
|
+
return <PlateStatic editor={editor} className={cn('text-sm', className)} />;
|
|
117
|
+
}
|