@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,668 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowTurnBackwardIcon,
|
|
3
|
+
ArrowTurnForwardIcon,
|
|
4
|
+
CheckListIcon,
|
|
5
|
+
FileExportIcon,
|
|
6
|
+
FileImportIcon,
|
|
7
|
+
GridTableIcon,
|
|
8
|
+
Heading01Icon,
|
|
9
|
+
Heading02Icon,
|
|
10
|
+
Heading03Icon,
|
|
11
|
+
LeftToRightListBulletIcon,
|
|
12
|
+
LeftToRightListNumberIcon,
|
|
13
|
+
Link01Icon,
|
|
14
|
+
ListChevronsDownUpIcon,
|
|
15
|
+
MoreHorizontalIcon,
|
|
16
|
+
PlusSignIcon,
|
|
17
|
+
QuoteDownIcon,
|
|
18
|
+
SourceCodeIcon,
|
|
19
|
+
SourceCodeSquareIcon,
|
|
20
|
+
TextAlignCenterIcon,
|
|
21
|
+
TextAlignJustifyCenterIcon,
|
|
22
|
+
TextAlignLeftIcon,
|
|
23
|
+
TextAlignRightIcon,
|
|
24
|
+
TextBoldIcon,
|
|
25
|
+
TextIcon,
|
|
26
|
+
TextItalicIcon,
|
|
27
|
+
TextStrikethroughIcon,
|
|
28
|
+
TextUnderlineIcon,
|
|
29
|
+
} from '@hugeicons/core-free-icons';
|
|
30
|
+
import { HugeiconsIcon } from '@hugeicons/react';
|
|
31
|
+
import type { Alignment } from '@platejs/basic-styles';
|
|
32
|
+
import { flip, offset, useFloatingToolbar, useFloatingToolbarState } from '@platejs/floating';
|
|
33
|
+
import { unwrapLink, upsertLink } from '@platejs/link';
|
|
34
|
+
import { ListStyleType, someList, someTodoList, toggleList } from '@platejs/list';
|
|
35
|
+
import { insertTable } from '@platejs/table';
|
|
36
|
+
import { someToggle } from '@platejs/toggle';
|
|
37
|
+
import { openNextToggles } from '@platejs/toggle/react';
|
|
38
|
+
import { Button, buttonVariants } from '@saena-io/ui/components/button';
|
|
39
|
+
import {
|
|
40
|
+
Dialog,
|
|
41
|
+
DialogContent,
|
|
42
|
+
DialogDescription,
|
|
43
|
+
DialogHeader,
|
|
44
|
+
DialogTitle,
|
|
45
|
+
} from '@saena-io/ui/components/dialog';
|
|
46
|
+
import { DropdownMenuItem } from '@saena-io/ui/components/dropdown-menu';
|
|
47
|
+
import { Input } from '@saena-io/ui/components/input';
|
|
48
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@saena-io/ui/components/popover';
|
|
49
|
+
import {
|
|
50
|
+
Toolbar,
|
|
51
|
+
type ToolbarGroup,
|
|
52
|
+
ToolbarItems,
|
|
53
|
+
type ToolbarNode,
|
|
54
|
+
} from '@saena-io/ui/components/toolbar';
|
|
55
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
56
|
+
import { KEYS } from 'platejs';
|
|
57
|
+
import { useEditorId, useEditorReadOnly, useEditorState, useEventEditorValue } from 'platejs/react';
|
|
58
|
+
import { useRef, useState } from 'react';
|
|
59
|
+
import type { RichTextExtension } from './extension';
|
|
60
|
+
import { exportMenuItems, importMenuItems } from './import-export-toolbar';
|
|
61
|
+
import type { RichTextEditor } from './plugins';
|
|
62
|
+
import { tableMenuNode } from './table-toolbar';
|
|
63
|
+
import { mergeExtensionItems } from './toolbar-slots';
|
|
64
|
+
|
|
65
|
+
// The rich-text toolbar, expressed as DATA. Each control is a node (button / menu / custom) in a group;
|
|
66
|
+
// the generic @saena-io/ui <Toolbar> renders them and folds whole groups into a single dropdown when the
|
|
67
|
+
// container is narrow (mobile). A feature package contributes the same node shapes — so a media addon's
|
|
68
|
+
// "video / image / file" buttons land in a group and miniaturise for free, no responsive code in the plugin.
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The focused editor, falling back to the last focused one. Opening a portal blurs the editor, so a control
|
|
72
|
+
* that writes on value-change must remember which pane (source / target) it drives. Kept null-safe because
|
|
73
|
+
* the toolbar lives in a dual-pane PlateController (no single concrete editor under it).
|
|
74
|
+
*/
|
|
75
|
+
export function useActiveEditor(): RichTextEditor | null {
|
|
76
|
+
const live = useEditorState() as RichTextEditor | null;
|
|
77
|
+
const ref = useRef<RichTextEditor | null>(null);
|
|
78
|
+
if (live) ref.current = live;
|
|
79
|
+
return live ?? ref.current;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Block types for the "turn into" menu and the heading entries of the insert menu. */
|
|
83
|
+
const BLOCK_TYPES = [
|
|
84
|
+
{ value: 'p', label: 'Text', icon: TextIcon },
|
|
85
|
+
{ value: 'h1', label: 'Heading 1', icon: Heading01Icon },
|
|
86
|
+
{ value: 'h2', label: 'Heading 2', icon: Heading02Icon },
|
|
87
|
+
{ value: 'h3', label: 'Heading 3', icon: Heading03Icon },
|
|
88
|
+
{ value: 'blockquote', label: 'Quote', icon: QuoteDownIcon },
|
|
89
|
+
] as const;
|
|
90
|
+
|
|
91
|
+
/** Bullet-list styles, each with a glyph matching the rendered marker. */
|
|
92
|
+
const BULLET_STYLES = [
|
|
93
|
+
{ value: ListStyleType.Disc, label: 'Default', shape: 'disc' },
|
|
94
|
+
{ value: ListStyleType.Circle, label: 'Circle', shape: 'circle' },
|
|
95
|
+
{ value: ListStyleType.Square, label: 'Square', shape: 'square' },
|
|
96
|
+
] as const;
|
|
97
|
+
|
|
98
|
+
/** Ordered-list styles. */
|
|
99
|
+
const ORDERED_STYLES = [
|
|
100
|
+
{ value: ListStyleType.Decimal, label: 'Decimal (1, 2, 3)' },
|
|
101
|
+
{ value: ListStyleType.LowerAlpha, label: 'Lower Alpha (a, b, c)' },
|
|
102
|
+
{ value: ListStyleType.UpperAlpha, label: 'Upper Alpha (A, B, C)' },
|
|
103
|
+
{ value: ListStyleType.LowerRoman, label: 'Lower Roman (i, ii, iii)' },
|
|
104
|
+
{ value: ListStyleType.UpperRoman, label: 'Upper Roman (I, II, III)' },
|
|
105
|
+
] as const;
|
|
106
|
+
|
|
107
|
+
/** Block alignment options. */
|
|
108
|
+
const ALIGN_TYPES = [
|
|
109
|
+
{ value: 'left', label: 'Align left', icon: TextAlignLeftIcon },
|
|
110
|
+
{ value: 'center', label: 'Align center', icon: TextAlignCenterIcon },
|
|
111
|
+
{ value: 'right', label: 'Align right', icon: TextAlignRightIcon },
|
|
112
|
+
{ value: 'justify', label: 'Justify', icon: TextAlignJustifyCenterIcon },
|
|
113
|
+
] as const;
|
|
114
|
+
|
|
115
|
+
/** The little bullet glyph next to each bullet style — matches the rendered marker. */
|
|
116
|
+
function BulletShape({ shape }: { shape: 'disc' | 'circle' | 'square' }) {
|
|
117
|
+
return (
|
|
118
|
+
<span
|
|
119
|
+
aria-hidden
|
|
120
|
+
className={cn(
|
|
121
|
+
'size-2 shrink-0',
|
|
122
|
+
shape === 'disc' && 'rounded-full bg-current',
|
|
123
|
+
shape === 'circle' && 'rounded-full border-[1.5px] border-current',
|
|
124
|
+
shape === 'square' && 'bg-current',
|
|
125
|
+
)}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const newBlock = (type: string) => ({ type, children: [{ text: '' }] });
|
|
131
|
+
|
|
132
|
+
/** Insert a new sibling block after the current one. */
|
|
133
|
+
function insertSibling(editor: RichTextEditor, node: object) {
|
|
134
|
+
const path = editor.api.block()?.[1];
|
|
135
|
+
const at = path ? [...path.slice(0, -1), path[path.length - 1] + 1] : undefined;
|
|
136
|
+
editor.tf.insertNodes(node as Parameters<RichTextEditor['tf']['insertNodes']>[0], {
|
|
137
|
+
...(at ? { at } : {}),
|
|
138
|
+
select: true,
|
|
139
|
+
});
|
|
140
|
+
editor.tf.focus();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function insertNewList(editor: RichTextEditor, style: ListStyleType) {
|
|
144
|
+
insertSibling(editor, newBlock('p'));
|
|
145
|
+
toggleList(editor, { listStyleType: style });
|
|
146
|
+
editor.tf.focus();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function insertTableBlock(editor: RichTextEditor) {
|
|
150
|
+
if (!editor.selection) {
|
|
151
|
+
const end = editor.api.end([]);
|
|
152
|
+
if (end) editor.tf.select(end);
|
|
153
|
+
}
|
|
154
|
+
insertTable(editor, { rowCount: 3, colCount: 3 }, { select: true });
|
|
155
|
+
editor.tf.focus();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- Link (custom node — a URL field). Expanded: a popover button. Collapsed: a submenu with the same
|
|
159
|
+
// form, so a text-input control still lives inside a folded group (your "custom UI in a group" case). ---
|
|
160
|
+
|
|
161
|
+
function useLinkState(editor: RichTextEditor | null) {
|
|
162
|
+
const entry = editor?.api.node({ match: { type: 'a' } });
|
|
163
|
+
const isLink = Boolean(entry);
|
|
164
|
+
const currentUrl = (entry?.[0] as { url?: string } | undefined)?.url ?? '';
|
|
165
|
+
return { isLink, currentUrl };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function LinkForm({
|
|
169
|
+
editor,
|
|
170
|
+
isLink,
|
|
171
|
+
currentUrl,
|
|
172
|
+
onDone,
|
|
173
|
+
}: {
|
|
174
|
+
editor: RichTextEditor | null;
|
|
175
|
+
isLink: boolean;
|
|
176
|
+
currentUrl: string;
|
|
177
|
+
onDone: () => void;
|
|
178
|
+
}) {
|
|
179
|
+
const [url, setUrl] = useState(currentUrl);
|
|
180
|
+
const apply = () => {
|
|
181
|
+
const value = url.trim();
|
|
182
|
+
if (editor && value) upsertLink(editor, { url: value });
|
|
183
|
+
onDone();
|
|
184
|
+
};
|
|
185
|
+
const remove = () => {
|
|
186
|
+
if (editor) unwrapLink(editor);
|
|
187
|
+
onDone();
|
|
188
|
+
};
|
|
189
|
+
return (
|
|
190
|
+
<div className="flex flex-col gap-2">
|
|
191
|
+
<Input
|
|
192
|
+
autoFocus
|
|
193
|
+
value={url}
|
|
194
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
195
|
+
placeholder="https://example.com"
|
|
196
|
+
onKeyDown={(e) => {
|
|
197
|
+
if (e.key === 'Enter') apply();
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
<div className="flex justify-end gap-2">
|
|
201
|
+
{isLink ? (
|
|
202
|
+
<Button variant="ghost" size="sm" onClick={remove}>
|
|
203
|
+
Remove
|
|
204
|
+
</Button>
|
|
205
|
+
) : null}
|
|
206
|
+
<Button size="sm" onClick={apply} disabled={!url.trim()}>
|
|
207
|
+
{isLink ? 'Update' : 'Add'}
|
|
208
|
+
</Button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function LinkPopover() {
|
|
215
|
+
const editor = useActiveEditor();
|
|
216
|
+
const [open, setOpen] = useState(false);
|
|
217
|
+
const { isLink, currentUrl } = useLinkState(editor);
|
|
218
|
+
return (
|
|
219
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
220
|
+
<PopoverTrigger
|
|
221
|
+
className={cn(buttonVariants({ variant: isLink ? 'secondary' : 'ghost', size: 'icon' }))}
|
|
222
|
+
aria-pressed={isLink}
|
|
223
|
+
title="Link"
|
|
224
|
+
>
|
|
225
|
+
<HugeiconsIcon icon={Link01Icon} strokeWidth={2} className="size-3.5" />
|
|
226
|
+
</PopoverTrigger>
|
|
227
|
+
<PopoverContent className="w-64" align="start">
|
|
228
|
+
<LinkForm
|
|
229
|
+
editor={editor}
|
|
230
|
+
isLink={isLink}
|
|
231
|
+
currentUrl={currentUrl}
|
|
232
|
+
onDone={() => setOpen(false)}
|
|
233
|
+
/>
|
|
234
|
+
</PopoverContent>
|
|
235
|
+
</Popover>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Collapsed form of the link control: a menu item that opens the link dialog. A menu can't host a text
|
|
240
|
+
* field — its typeahead competes with typing — so the form opens as a separate modal surface instead. */
|
|
241
|
+
function LinkMenuItem({ onSelect }: { onSelect?: () => void }) {
|
|
242
|
+
return (
|
|
243
|
+
<DropdownMenuItem onMouseDown={(e) => e.preventDefault()} onClick={() => onSelect?.()}>
|
|
244
|
+
<HugeiconsIcon icon={Link01Icon} strokeWidth={2} className="size-4" />
|
|
245
|
+
Link…
|
|
246
|
+
</DropdownMenuItem>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The link editor as a modal dialog — opened from the collapsed Format group's "Link…" entry. */
|
|
251
|
+
function LinkDialog({
|
|
252
|
+
open,
|
|
253
|
+
onOpenChange,
|
|
254
|
+
editor,
|
|
255
|
+
}: {
|
|
256
|
+
open: boolean;
|
|
257
|
+
onOpenChange: (open: boolean) => void;
|
|
258
|
+
editor: RichTextEditor | null;
|
|
259
|
+
}) {
|
|
260
|
+
const { isLink, currentUrl } = useLinkState(editor);
|
|
261
|
+
return (
|
|
262
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
263
|
+
<DialogContent className="max-w-sm">
|
|
264
|
+
<DialogHeader>
|
|
265
|
+
<DialogTitle>{isLink ? 'Edit link' : 'Add link'}</DialogTitle>
|
|
266
|
+
<DialogDescription className="sr-only">
|
|
267
|
+
Enter a URL for the selected text.
|
|
268
|
+
</DialogDescription>
|
|
269
|
+
</DialogHeader>
|
|
270
|
+
<LinkForm
|
|
271
|
+
editor={editor}
|
|
272
|
+
isLink={isLink}
|
|
273
|
+
currentUrl={currentUrl}
|
|
274
|
+
onDone={() => onOpenChange(false)}
|
|
275
|
+
/>
|
|
276
|
+
</DialogContent>
|
|
277
|
+
</Dialog>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Group config: built from the active editor each render, so active states stay live. ---
|
|
282
|
+
|
|
283
|
+
type PickFile = (accept: string, handler: (file: File) => void | Promise<void>) => void;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The Format group (inline marks + link). Shared by the fixed toolbar and the floating selection toolbar,
|
|
287
|
+
* so the latter reads one group instead of building the whole toolbar config just to discard the rest.
|
|
288
|
+
*/
|
|
289
|
+
function buildFormatGroup(
|
|
290
|
+
editor: RichTextEditor | null,
|
|
291
|
+
ctx?: { openLink?: () => void },
|
|
292
|
+
): ToolbarGroup {
|
|
293
|
+
const marks = (editor?.api?.marks?.() ?? {}) as Record<string, unknown>;
|
|
294
|
+
const mark = (id: string, label: string, icon: typeof TextBoldIcon): ToolbarNode => ({
|
|
295
|
+
kind: 'button',
|
|
296
|
+
id,
|
|
297
|
+
label,
|
|
298
|
+
icon,
|
|
299
|
+
active: Boolean(marks[id]),
|
|
300
|
+
onSelect: () => editor?.tf.toggleMark(id),
|
|
301
|
+
});
|
|
302
|
+
return {
|
|
303
|
+
// Text styling is also in the floating selection toolbar, so on small screens hide it here entirely
|
|
304
|
+
// (rather than fold it) — select text to get the marks. Stays expanded on desktop. (No `icon` since it
|
|
305
|
+
// never folds to a trigger.)
|
|
306
|
+
id: 'format',
|
|
307
|
+
label: 'Format',
|
|
308
|
+
hideBelow: 'md',
|
|
309
|
+
items: [
|
|
310
|
+
mark('bold', 'Bold', TextBoldIcon),
|
|
311
|
+
mark('italic', 'Italic', TextItalicIcon),
|
|
312
|
+
mark('underline', 'Underline', TextUnderlineIcon),
|
|
313
|
+
mark('strikethrough', 'Strikethrough', TextStrikethroughIcon),
|
|
314
|
+
mark('code', 'Inline code', SourceCodeIcon),
|
|
315
|
+
{
|
|
316
|
+
kind: 'custom',
|
|
317
|
+
id: 'link',
|
|
318
|
+
expanded: <LinkPopover />,
|
|
319
|
+
collapsed: <LinkMenuItem onSelect={ctx?.openLink} />,
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function buildGroups(
|
|
326
|
+
editor: RichTextEditor | null,
|
|
327
|
+
ctx?: { openLink?: () => void; pickFile?: PickFile },
|
|
328
|
+
): ToolbarGroup[] {
|
|
329
|
+
const blockType = (editor?.api?.block?.()?.[0]?.type as string | undefined) ?? 'p';
|
|
330
|
+
const rawAlign = (editor?.api?.block?.()?.[0] as { align?: string } | undefined)?.align ?? 'left';
|
|
331
|
+
const align = rawAlign === 'start' ? 'left' : rawAlign === 'end' ? 'right' : rawAlign;
|
|
332
|
+
|
|
333
|
+
const turnInto: ToolbarNode = {
|
|
334
|
+
kind: 'menu',
|
|
335
|
+
id: 'turn-into',
|
|
336
|
+
label: 'Turn into',
|
|
337
|
+
valueLabel: BLOCK_TYPES.find((b) => b.value === blockType)?.label ?? 'Text',
|
|
338
|
+
icon: BLOCK_TYPES.find((b) => b.value === blockType)?.icon ?? TextIcon,
|
|
339
|
+
variant: 'labeled',
|
|
340
|
+
triggerClassName: 'w-32',
|
|
341
|
+
radio: true,
|
|
342
|
+
items: BLOCK_TYPES.map((b) => ({
|
|
343
|
+
kind: 'button',
|
|
344
|
+
id: b.value,
|
|
345
|
+
label: b.label,
|
|
346
|
+
icon: b.icon,
|
|
347
|
+
active: blockType === b.value,
|
|
348
|
+
onSelect: () => {
|
|
349
|
+
if (!editor) return;
|
|
350
|
+
if (b.value === 'blockquote') editor.tf.toggleBlock('blockquote', { wrap: true });
|
|
351
|
+
else editor.tf.toggleBlock(b.value);
|
|
352
|
+
editor.tf.focus();
|
|
353
|
+
},
|
|
354
|
+
})),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Align is its own group of buttons (one per alignment) — expanded shows all four; it only folds into a
|
|
358
|
+
// dropdown on small screens. The collapsed-trigger icon reflects the current alignment.
|
|
359
|
+
const alignGroup: ToolbarGroup = {
|
|
360
|
+
id: 'align',
|
|
361
|
+
label: 'Align',
|
|
362
|
+
icon: ALIGN_TYPES.find((a) => a.value === align)?.icon ?? TextAlignLeftIcon,
|
|
363
|
+
collapse: 'md',
|
|
364
|
+
items: ALIGN_TYPES.map(
|
|
365
|
+
(a): ToolbarNode => ({
|
|
366
|
+
kind: 'button',
|
|
367
|
+
id: `align-${a.value}`,
|
|
368
|
+
label: a.label,
|
|
369
|
+
icon: a.icon,
|
|
370
|
+
active: align === a.value,
|
|
371
|
+
onSelect: () => {
|
|
372
|
+
editor?.tf.textAlign.setNodes(a.value as Alignment);
|
|
373
|
+
editor?.tf.focus();
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
),
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const insertMenu: ToolbarNode = {
|
|
380
|
+
kind: 'menu',
|
|
381
|
+
id: 'insert',
|
|
382
|
+
label: 'Insert',
|
|
383
|
+
icon: PlusSignIcon,
|
|
384
|
+
variant: 'plain',
|
|
385
|
+
items: [
|
|
386
|
+
...BLOCK_TYPES.filter((b) => b.value !== 'p').map(
|
|
387
|
+
(b): ToolbarNode => ({
|
|
388
|
+
kind: 'button',
|
|
389
|
+
id: `insert-${b.value}`,
|
|
390
|
+
label: b.label,
|
|
391
|
+
icon: b.icon,
|
|
392
|
+
onSelect: () => editor && insertSibling(editor, newBlock(b.value)),
|
|
393
|
+
}),
|
|
394
|
+
),
|
|
395
|
+
{
|
|
396
|
+
kind: 'button',
|
|
397
|
+
id: 'insert-bulleted',
|
|
398
|
+
label: 'Bulleted list',
|
|
399
|
+
icon: LeftToRightListBulletIcon,
|
|
400
|
+
onSelect: () => editor && insertNewList(editor, ListStyleType.Disc),
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
kind: 'button',
|
|
404
|
+
id: 'insert-numbered',
|
|
405
|
+
label: 'Numbered list',
|
|
406
|
+
icon: LeftToRightListNumberIcon,
|
|
407
|
+
onSelect: () => editor && insertNewList(editor, ListStyleType.Decimal),
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
kind: 'button',
|
|
411
|
+
id: 'insert-code',
|
|
412
|
+
label: 'Code block',
|
|
413
|
+
icon: SourceCodeSquareIcon,
|
|
414
|
+
onSelect: () =>
|
|
415
|
+
editor &&
|
|
416
|
+
insertSibling(editor, {
|
|
417
|
+
type: 'code_block',
|
|
418
|
+
children: [{ type: 'code_line', children: [{ text: '' }] }],
|
|
419
|
+
}),
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
kind: 'button',
|
|
423
|
+
id: 'insert-table',
|
|
424
|
+
label: 'Table',
|
|
425
|
+
icon: GridTableIcon,
|
|
426
|
+
onSelect: () => editor && insertTableBlock(editor),
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const applyList = (
|
|
432
|
+
listStyleType: NonNullable<Parameters<typeof toggleList>[1]>['listStyleType'],
|
|
433
|
+
) => {
|
|
434
|
+
if (!editor) return;
|
|
435
|
+
toggleList(editor, { listStyleType });
|
|
436
|
+
editor.tf.focus();
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const bulletList: ToolbarNode = {
|
|
440
|
+
kind: 'menu',
|
|
441
|
+
id: 'bulleted-list',
|
|
442
|
+
label: 'Bulleted list',
|
|
443
|
+
icon: LeftToRightListBulletIcon,
|
|
444
|
+
variant: 'split',
|
|
445
|
+
active: editor ? BULLET_STYLES.some((b) => someList(editor, b.value)) : false,
|
|
446
|
+
onPrimary: () => applyList(ListStyleType.Disc),
|
|
447
|
+
radio: true,
|
|
448
|
+
items: BULLET_STYLES.map(
|
|
449
|
+
(b): ToolbarNode => ({
|
|
450
|
+
kind: 'button',
|
|
451
|
+
id: b.value,
|
|
452
|
+
label: b.label,
|
|
453
|
+
iconNode: <BulletShape shape={b.shape} />,
|
|
454
|
+
active: editor ? someList(editor, b.value) : false,
|
|
455
|
+
onSelect: () => applyList(b.value),
|
|
456
|
+
}),
|
|
457
|
+
),
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const numberedList: ToolbarNode = {
|
|
461
|
+
kind: 'menu',
|
|
462
|
+
id: 'numbered-list',
|
|
463
|
+
label: 'Numbered list',
|
|
464
|
+
icon: LeftToRightListNumberIcon,
|
|
465
|
+
variant: 'split',
|
|
466
|
+
active: editor ? ORDERED_STYLES.some((o) => someList(editor, o.value)) : false,
|
|
467
|
+
onPrimary: () => applyList(ListStyleType.Decimal),
|
|
468
|
+
radio: true,
|
|
469
|
+
items: ORDERED_STYLES.map(
|
|
470
|
+
(o): ToolbarNode => ({
|
|
471
|
+
kind: 'button',
|
|
472
|
+
id: o.value,
|
|
473
|
+
label: o.label,
|
|
474
|
+
active: editor ? someList(editor, o.value) : false,
|
|
475
|
+
onSelect: () => applyList(o.value),
|
|
476
|
+
}),
|
|
477
|
+
),
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const todo: ToolbarNode = {
|
|
481
|
+
kind: 'button',
|
|
482
|
+
id: 'todo-list',
|
|
483
|
+
label: 'To-do list',
|
|
484
|
+
icon: CheckListIcon,
|
|
485
|
+
active: editor ? someTodoList(editor) : false,
|
|
486
|
+
onSelect: () => applyList(KEYS.listTodo),
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const toggleListNode: ToolbarNode = {
|
|
490
|
+
kind: 'button',
|
|
491
|
+
id: 'toggle-list',
|
|
492
|
+
label: 'Toggle list',
|
|
493
|
+
icon: ListChevronsDownUpIcon,
|
|
494
|
+
active: editor ? Boolean(someToggle(editor)) : false,
|
|
495
|
+
onSelect: () => {
|
|
496
|
+
if (!editor) return;
|
|
497
|
+
openNextToggles(editor);
|
|
498
|
+
editor.tf.toggleBlock(KEYS.toggle);
|
|
499
|
+
editor.tf.collapse();
|
|
500
|
+
editor.tf.focus();
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const importMenu: ToolbarNode = {
|
|
505
|
+
kind: 'menu',
|
|
506
|
+
id: 'import',
|
|
507
|
+
label: 'Import',
|
|
508
|
+
icon: FileImportIcon,
|
|
509
|
+
variant: 'plain',
|
|
510
|
+
items: importMenuItems(editor, ctx?.pickFile ?? (() => undefined)).map(
|
|
511
|
+
(it): ToolbarNode => ({ kind: 'button', id: it.id, label: it.label, onSelect: it.run }),
|
|
512
|
+
),
|
|
513
|
+
};
|
|
514
|
+
const exportMenu: ToolbarNode = {
|
|
515
|
+
kind: 'menu',
|
|
516
|
+
id: 'export',
|
|
517
|
+
label: 'Export',
|
|
518
|
+
icon: FileExportIcon,
|
|
519
|
+
variant: 'plain',
|
|
520
|
+
items: exportMenuItems(editor).map(
|
|
521
|
+
(it): ToolbarNode => ({ kind: 'button', id: it.id, label: it.label, onSelect: it.run }),
|
|
522
|
+
),
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// Undo / redo + Import / export — document-level actions, all the way at the left. Folds to a single
|
|
526
|
+
// "more" (3-dots) dropdown on smaller screens.
|
|
527
|
+
const documentGroup: ToolbarGroup = {
|
|
528
|
+
id: 'document',
|
|
529
|
+
label: 'Edit',
|
|
530
|
+
icon: MoreHorizontalIcon,
|
|
531
|
+
collapse: 'lg',
|
|
532
|
+
items: [
|
|
533
|
+
{
|
|
534
|
+
kind: 'button',
|
|
535
|
+
id: 'undo',
|
|
536
|
+
label: 'Undo',
|
|
537
|
+
icon: ArrowTurnBackwardIcon,
|
|
538
|
+
onSelect: () => editor?.undo(),
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
kind: 'button',
|
|
542
|
+
id: 'redo',
|
|
543
|
+
label: 'Redo',
|
|
544
|
+
icon: ArrowTurnForwardIcon,
|
|
545
|
+
onSelect: () => editor?.redo(),
|
|
546
|
+
},
|
|
547
|
+
importMenu,
|
|
548
|
+
exportMenu,
|
|
549
|
+
],
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
return [
|
|
553
|
+
documentGroup,
|
|
554
|
+
{
|
|
555
|
+
// Insert + Turn-into never fold — kept as-is on every screen size (we have the room).
|
|
556
|
+
id: 'block',
|
|
557
|
+
label: 'Block',
|
|
558
|
+
icon: TextIcon,
|
|
559
|
+
collapse: 'never',
|
|
560
|
+
items: [insertMenu, turnInto],
|
|
561
|
+
},
|
|
562
|
+
buildFormatGroup(editor, ctx),
|
|
563
|
+
alignGroup,
|
|
564
|
+
{
|
|
565
|
+
id: 'lists',
|
|
566
|
+
label: 'Lists',
|
|
567
|
+
icon: LeftToRightListBulletIcon,
|
|
568
|
+
collapse: 'md',
|
|
569
|
+
items: [bulletList, numberedList, todo, toggleListNode],
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
id: 'objects',
|
|
573
|
+
label: 'Table',
|
|
574
|
+
icon: GridTableIcon,
|
|
575
|
+
collapse: 'never',
|
|
576
|
+
items: [tableMenuNode(editor)],
|
|
577
|
+
},
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The shared rich-text toolbar. Render once inside a <PlateController> above the editor pane(s). Built as a
|
|
583
|
+
* config and rendered by the generic <Toolbar>, which folds groups into dropdowns as the width shrinks.
|
|
584
|
+
* Extension-contributed controls (RichTextExtension.toolbar) merge into the group named by each item's
|
|
585
|
+
* `slot`, sorted by `order` — see mergeExtensionItems / extension.ts.
|
|
586
|
+
*/
|
|
587
|
+
export function RichTextToolbar({
|
|
588
|
+
className,
|
|
589
|
+
extensions = [],
|
|
590
|
+
}: {
|
|
591
|
+
className?: string;
|
|
592
|
+
extensions?: RichTextExtension[];
|
|
593
|
+
}) {
|
|
594
|
+
const editor = useActiveEditor();
|
|
595
|
+
const [linkOpen, setLinkOpen] = useState(false);
|
|
596
|
+
// One hidden file input drives every Import action, re-targeted (accept + handler) before each open.
|
|
597
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
598
|
+
const onPick = useRef<(file: File) => void | Promise<void>>(() => undefined);
|
|
599
|
+
const pickFile: PickFile = (accept, handler) => {
|
|
600
|
+
onPick.current = handler;
|
|
601
|
+
const input = fileInputRef.current;
|
|
602
|
+
if (!input) return;
|
|
603
|
+
input.value = '';
|
|
604
|
+
input.accept = accept;
|
|
605
|
+
input.click();
|
|
606
|
+
};
|
|
607
|
+
const groups = buildGroups(editor, { openLink: () => setLinkOpen(true), pickFile });
|
|
608
|
+
mergeExtensionItems(groups, extensions);
|
|
609
|
+
return (
|
|
610
|
+
<>
|
|
611
|
+
<Toolbar groups={groups} className={className} />
|
|
612
|
+
<input
|
|
613
|
+
ref={fileInputRef}
|
|
614
|
+
type="file"
|
|
615
|
+
className="hidden"
|
|
616
|
+
onChange={(e) => {
|
|
617
|
+
const file = e.target.files?.[0];
|
|
618
|
+
if (file) void onPick.current(file);
|
|
619
|
+
}}
|
|
620
|
+
/>
|
|
621
|
+
<LinkDialog open={linkOpen} onOpenChange={setLinkOpen} editor={editor} />
|
|
622
|
+
</>
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* A floating toolbar over a non-collapsed selection. Registered as a plugin's render.afterEditable (see
|
|
628
|
+
* rich-text-editor.tsx), so it mounts INSIDE each concrete editor — safe under the dual-pane controller. It
|
|
629
|
+
* reuses the Format group (marks + link), rendered without collapse and without the fixed-toolbar chrome.
|
|
630
|
+
*/
|
|
631
|
+
export function FloatingRichTextToolbar() {
|
|
632
|
+
const editorId = useEditorId();
|
|
633
|
+
const focusedEditorId = useEventEditorValue('focus');
|
|
634
|
+
const readOnly = useEditorReadOnly();
|
|
635
|
+
const editor = useActiveEditor();
|
|
636
|
+
const state = useFloatingToolbarState({
|
|
637
|
+
editorId,
|
|
638
|
+
focusedEditorId,
|
|
639
|
+
floatingOptions: {
|
|
640
|
+
placement: 'top',
|
|
641
|
+
middleware: [
|
|
642
|
+
offset(8),
|
|
643
|
+
flip({
|
|
644
|
+
fallbackPlacements: ['top-start', 'top-end', 'bottom-start', 'bottom-end'],
|
|
645
|
+
padding: 12,
|
|
646
|
+
}),
|
|
647
|
+
],
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
const { clickOutsideRef, hidden, props, ref } = useFloatingToolbar(state);
|
|
651
|
+
if (readOnly || hidden) return null;
|
|
652
|
+
const formatGroup = buildFormatGroup(editor);
|
|
653
|
+
return (
|
|
654
|
+
<div ref={clickOutsideRef}>
|
|
655
|
+
<div
|
|
656
|
+
ref={ref}
|
|
657
|
+
{...props}
|
|
658
|
+
className={cn(
|
|
659
|
+
'absolute z-50 flex w-auto items-center rounded-md border bg-popover p-1 text-popover-foreground shadow-md print:hidden',
|
|
660
|
+
)}
|
|
661
|
+
>
|
|
662
|
+
{/* ToolbarItems (not <Toolbar>) — the latter sets an @container, which stops the bar sizing to its
|
|
663
|
+
content in this shrink-to-fit popover and broke the background. */}
|
|
664
|
+
<ToolbarItems items={formatGroup.items} />
|
|
665
|
+
</div>
|
|
666
|
+
</div>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type UseChatHelpers, useChat as useBaseChat } from '@ai-sdk/react';
|
|
4
|
+
import { AIChatPlugin } from '@platejs/ai/react';
|
|
5
|
+
import { DefaultChatTransport, type UIMessage } from 'ai';
|
|
6
|
+
import type { PlateEditor } from 'platejs/react';
|
|
7
|
+
import * as React from 'react';
|
|
8
|
+
|
|
9
|
+
// The editor's chat instance (ADR-0010). @platejs/ai's AIChatPlugin does NOT own an HTTP client — it borrows
|
|
10
|
+
// an @ai-sdk/react `useChat` instance that we create and inject via editor.setOption(AIChatPlugin, 'chat').
|
|
11
|
+
// The transport points at the SAENA streaming route (/api/ai/command by default), which returns an AI-SDK UI
|
|
12
|
+
// Message Stream. Adapted from @platejs/ai's example, stripped of its faker mock + comment/table data-part
|
|
13
|
+
// handling (SAENA's server emits plain text/reasoning parts only).
|
|
14
|
+
|
|
15
|
+
export type ChatMessage = UIMessage;
|
|
16
|
+
|
|
17
|
+
/** Create + own the editor's chat, inject it into AIChatPlugin, and keep it in sync. Call inside the
|
|
18
|
+
* plugin's `useHooks` (which runs within the editor's React context). */
|
|
19
|
+
export function useAiChat(editor: PlateEditor, api: string): UseChatHelpers<ChatMessage> {
|
|
20
|
+
const transport = React.useMemo(() => new DefaultChatTransport<ChatMessage>({ api }), [api]);
|
|
21
|
+
const chat = useBaseChat<ChatMessage>({ id: 'editor', transport });
|
|
22
|
+
|
|
23
|
+
// Depend on the chat's STABLE fields, NOT the `chat` object — useChat returns a fresh object every render,
|
|
24
|
+
// so `[chat]` would re-run this effect → setOption → re-render → loop ("Maximum update depth exceeded").
|
|
25
|
+
// status/messages/error only change on real chat events, which is exactly when the plugin needs the update.
|
|
26
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally keyed on chat's stable fields.
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
// The plugin's `chat` option is typed for its own ChatMessage (UIMessage + comment/table data parts);
|
|
29
|
+
// ours carries only text/reasoning parts, so the structural types don't line up. The runtime contract
|
|
30
|
+
// (sendMessage/regenerate/stop/setMessages/status/messages) matches — cast past the cosmetic mismatch.
|
|
31
|
+
editor.setOption(AIChatPlugin, 'chat', chat as never);
|
|
32
|
+
}, [editor, chat.status, chat.messages, chat.error]);
|
|
33
|
+
|
|
34
|
+
return chat;
|
|
35
|
+
}
|