@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.
Files changed (100) hide show
  1. package/dist/index.js +9 -9
  2. package/package.json +1 -1
  3. package/template/base/package.json +44 -2
  4. package/template/base/scripts/ui-update.ts +83 -0
  5. package/template/base/src/components/ui/accordion.tsx +75 -0
  6. package/template/base/src/components/ui/alert-dialog.tsx +162 -0
  7. package/template/base/src/components/ui/alert.tsx +73 -0
  8. package/template/base/src/components/ui/app-sidebar.tsx +183 -0
  9. package/template/base/src/components/ui/aspect-ratio.tsx +22 -0
  10. package/template/base/src/components/ui/asset-input.tsx +211 -0
  11. package/template/base/src/components/ui/avatar.tsx +91 -0
  12. package/template/base/src/components/ui/badge.tsx +50 -0
  13. package/template/base/src/components/ui/breadcrumb.tsx +104 -0
  14. package/template/base/src/components/ui/button-group.tsx +78 -0
  15. package/template/base/src/components/ui/button.tsx +56 -0
  16. package/template/base/src/components/ui/calendar.tsx +205 -0
  17. package/template/base/src/components/ui/card.tsx +85 -0
  18. package/template/base/src/components/ui/carousel.tsx +232 -0
  19. package/template/base/src/components/ui/chart.tsx +337 -0
  20. package/template/base/src/components/ui/checkbox.tsx +29 -0
  21. package/template/base/src/components/ui/collapsible.tsx +15 -0
  22. package/template/base/src/components/ui/combobox.tsx +276 -0
  23. package/template/base/src/components/ui/command.tsx +190 -0
  24. package/template/base/src/components/ui/context-menu.tsx +243 -0
  25. package/template/base/src/components/ui/dialog.tsx +134 -0
  26. package/template/base/src/components/ui/direction.tsx +4 -0
  27. package/template/base/src/components/ui/drawer.tsx +120 -0
  28. package/template/base/src/components/ui/dropdown-menu.tsx +254 -0
  29. package/template/base/src/components/ui/empty.tsx +94 -0
  30. package/template/base/src/components/ui/field.tsx +222 -0
  31. package/template/base/src/components/ui/focal-point-picker.tsx +175 -0
  32. package/template/base/src/components/ui/hover-card.tsx +46 -0
  33. package/template/base/src/components/ui/input-group.tsx +149 -0
  34. package/template/base/src/components/ui/input-otp.tsx +85 -0
  35. package/template/base/src/components/ui/input.tsx +20 -0
  36. package/template/base/src/components/ui/item.tsx +188 -0
  37. package/template/base/src/components/ui/kbd.tsx +26 -0
  38. package/template/base/src/components/ui/label.tsx +20 -0
  39. package/template/base/src/components/ui/menubar.tsx +268 -0
  40. package/template/base/src/components/ui/native-select.tsx +58 -0
  41. package/template/base/src/components/ui/nav-main.tsx +70 -0
  42. package/template/base/src/components/ui/nav-projects.tsx +97 -0
  43. package/template/base/src/components/ui/nav-secondary.tsx +37 -0
  44. package/template/base/src/components/ui/nav-user.tsx +108 -0
  45. package/template/base/src/components/ui/navigation-menu.tsx +164 -0
  46. package/template/base/src/components/ui/pagination.tsx +123 -0
  47. package/template/base/src/components/ui/popover.tsx +80 -0
  48. package/template/base/src/components/ui/progress.tsx +66 -0
  49. package/template/base/src/components/ui/radio-group.tsx +36 -0
  50. package/template/base/src/components/ui/resizable.tsx +42 -0
  51. package/template/base/src/components/ui/rich-text/ai-chat-editor.tsx +20 -0
  52. package/template/base/src/components/ui/rich-text/ai-command.tsx +90 -0
  53. package/template/base/src/components/ui/rich-text/ai-copilot.tsx +67 -0
  54. package/template/base/src/components/ui/rich-text/ai-menu.tsx +456 -0
  55. package/template/base/src/components/ui/rich-text/ai-node.tsx +42 -0
  56. package/template/base/src/components/ui/rich-text/ai-toolbar-button.tsx +29 -0
  57. package/template/base/src/components/ui/rich-text/block-draggable.tsx +187 -0
  58. package/template/base/src/components/ui/rich-text/block-selection.tsx +17 -0
  59. package/template/base/src/components/ui/rich-text/code-block-node.tsx +204 -0
  60. package/template/base/src/components/ui/rich-text/codec.ts +63 -0
  61. package/template/base/src/components/ui/rich-text/extension.ts +53 -0
  62. package/template/base/src/components/ui/rich-text/ghost-text.tsx +23 -0
  63. package/template/base/src/components/ui/rich-text/import-export-toolbar.tsx +103 -0
  64. package/template/base/src/components/ui/rich-text/link.tsx +18 -0
  65. package/template/base/src/components/ui/rich-text/list-node.tsx +65 -0
  66. package/template/base/src/components/ui/rich-text/nodes.tsx +44 -0
  67. package/template/base/src/components/ui/rich-text/plugins.ts +233 -0
  68. package/template/base/src/components/ui/rich-text/rich-text-editor.tsx +82 -0
  69. package/template/base/src/components/ui/rich-text/static.tsx +117 -0
  70. package/template/base/src/components/ui/rich-text/table-node.tsx +934 -0
  71. package/template/base/src/components/ui/rich-text/table-toolbar.tsx +232 -0
  72. package/template/base/src/components/ui/rich-text/toggle-node.tsx +36 -0
  73. package/template/base/src/components/ui/rich-text/toolbar-slots.ts +41 -0
  74. package/template/base/src/components/ui/rich-text/toolbar.tsx +668 -0
  75. package/template/base/src/components/ui/rich-text/use-ai-chat.ts +35 -0
  76. package/template/base/src/components/ui/rich-text/variable-type.ts +4 -0
  77. package/template/base/src/components/ui/rich-text/variable.tsx +97 -0
  78. package/template/base/src/components/ui/scroll-area.tsx +49 -0
  79. package/template/base/src/components/ui/select.tsx +202 -0
  80. package/template/base/src/components/ui/separator.tsx +19 -0
  81. package/template/base/src/components/ui/sheet.tsx +126 -0
  82. package/template/base/src/components/ui/sidebar.tsx +695 -0
  83. package/template/base/src/components/ui/skeleton.tsx +13 -0
  84. package/template/base/src/components/ui/slider.tsx +52 -0
  85. package/template/base/src/components/ui/sonner.tsx +50 -0
  86. package/template/base/src/components/ui/spinner.tsx +18 -0
  87. package/template/base/src/components/ui/switch.tsx +30 -0
  88. package/template/base/src/components/ui/table.tsx +89 -0
  89. package/template/base/src/components/ui/tabs.tsx +73 -0
  90. package/template/base/src/components/ui/textarea.tsx +18 -0
  91. package/template/base/src/components/ui/toggle-group.tsx +85 -0
  92. package/template/base/src/components/ui/toggle.tsx +45 -0
  93. package/template/base/src/components/ui/toolbar.tsx +451 -0
  94. package/template/base/src/components/ui/tooltip.tsx +52 -0
  95. package/template/base/src/hooks/use-mobile.ts +19 -0
  96. package/template/base/src/lib/utils.ts +6 -0
  97. package/template/base/src/routes/__root.tsx +1 -1
  98. package/template/base/src/server/auth.ts +2 -2
  99. package/template/base/src/styles/globals.css +230 -0
  100. 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
+ }