@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,187 @@
|
|
|
1
|
+
import { GripVerticalIcon } from '@hugeicons/core-free-icons';
|
|
2
|
+
import { HugeiconsIcon } from '@hugeicons/react';
|
|
3
|
+
import { DndPlugin, useDraggable, useDropLine } from '@platejs/dnd';
|
|
4
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
5
|
+
import { KEYS, isType } from 'platejs';
|
|
6
|
+
import {
|
|
7
|
+
MemoizedChildren,
|
|
8
|
+
type PlateElementProps,
|
|
9
|
+
type RenderNodeWrapper,
|
|
10
|
+
useEditorRef,
|
|
11
|
+
useElement,
|
|
12
|
+
} from 'platejs/react';
|
|
13
|
+
import { type Ref, type RefObject, useEffect, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
// A per-block drag handle. Registered as DndPlugin's render.aboveNodes (see rich-text-editor.tsx), so it
|
|
16
|
+
// wraps every top-level block (paragraphs, headings, quote, code, lists, table). Hovering a block reveals a
|
|
17
|
+
// grip in the left gutter; dragging reorders the block and a drop line previews where it lands. The single
|
|
18
|
+
// react-dnd backend is provided once at the root (RichTextDndProvider) so the two translation panes don't
|
|
19
|
+
// double-mount it — hence we wire only render.aboveNodes here, not the example's per-editor aboveSlate.
|
|
20
|
+
// Table rows keep their own in-table drag handle (table-node.tsx); this only adds the whole-block handle.
|
|
21
|
+
|
|
22
|
+
const UNDRAGGABLE_TYPES = [KEYS.tr, KEYS.td, KEYS.column];
|
|
23
|
+
|
|
24
|
+
export const BlockDraggable: RenderNodeWrapper = ({ editor, element, path }) => {
|
|
25
|
+
// Only top-level blocks get a handle; never table rows/cells/columns (they drag from inside the table).
|
|
26
|
+
if (editor.dom.readOnly) return;
|
|
27
|
+
if (path.length !== 1) return;
|
|
28
|
+
if (isType(editor, element, UNDRAGGABLE_TYPES)) return;
|
|
29
|
+
return (props) => <DraggableBlock {...props} />;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function DraggableBlock(props: PlateElementProps) {
|
|
33
|
+
const { children, element } = props;
|
|
34
|
+
const editor = useEditorRef();
|
|
35
|
+
const { isAboutToDrag, isDragging, nodeRef, previewRef, handleRef } = useDraggable({ element });
|
|
36
|
+
// Align the grip with the FIRST LINE of text: the wrapper's top sits above the text by the block's
|
|
37
|
+
// top margin (large for headings), so offset by margin-top and center the 24px grip on the line height.
|
|
38
|
+
const [handleTop, setHandleTop] = useState(0);
|
|
39
|
+
|
|
40
|
+
// The drag image is a CLONE placed in the dedicated `previewRef` element below (not the live content) —
|
|
41
|
+
// an in-flow content node snapshots the whole editor inside the fixed sheet. Reveal it just before the
|
|
42
|
+
// drag starts (so the browser captures a painted image) and clear it when the drag ends.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (isAboutToDrag) previewRef.current?.classList.remove('opacity-0');
|
|
45
|
+
}, [isAboutToDrag, previewRef]);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!isDragging && previewRef.current) {
|
|
48
|
+
previewRef.current.replaceChildren();
|
|
49
|
+
previewRef.current.classList.add('hidden');
|
|
50
|
+
}
|
|
51
|
+
}, [isDragging, previewRef]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
// Named group (`/drag`) so the surrounding card's own `group` can't trigger the hover reveal — an
|
|
56
|
+
// unnamed `group-hover` would match any ancestor `.group`, showing every handle at once.
|
|
57
|
+
className={cn('group/drag relative', isDragging && 'opacity-50')}
|
|
58
|
+
onMouseEnter={() => {
|
|
59
|
+
if (isDragging) return;
|
|
60
|
+
const dom = editor.api.toDOMNode(element);
|
|
61
|
+
if (!dom) return;
|
|
62
|
+
const cs = getComputedStyle(dom);
|
|
63
|
+
const marginTop = Number.parseFloat(cs.marginTop) || 0;
|
|
64
|
+
const lineHeight = Number.parseFloat(cs.lineHeight);
|
|
65
|
+
const center = Number.isFinite(lineHeight) ? (lineHeight - 24) / 2 : 0;
|
|
66
|
+
setHandleTop(marginTop + center);
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{/* Left gutter — hidden until THIS block is hovered. */}
|
|
70
|
+
<div
|
|
71
|
+
className="-translate-x-full absolute left-0 flex select-none pr-1 opacity-0 transition-opacity group-hover/drag:opacity-100"
|
|
72
|
+
style={{ top: `${handleTop}px` }}
|
|
73
|
+
contentEditable={false}
|
|
74
|
+
>
|
|
75
|
+
<DragHandle dragRef={handleRef} previewRef={previewRef} />
|
|
76
|
+
</div>
|
|
77
|
+
{/* Dedicated drag-preview container — overlays the block (top:0) and stays hidden until a drag starts,
|
|
78
|
+
when DragHandle fills it with a clean clone of the block. Using an isolated, absolutely-positioned
|
|
79
|
+
element keeps the HTML5 drag image to just the block. */}
|
|
80
|
+
<div
|
|
81
|
+
ref={previewRef}
|
|
82
|
+
className="absolute top-0 left-0 hidden w-full"
|
|
83
|
+
contentEditable={false}
|
|
84
|
+
/>
|
|
85
|
+
<div ref={nodeRef} className="flow-root">
|
|
86
|
+
<MemoizedChildren>{children}</MemoizedChildren>
|
|
87
|
+
<DropLine />
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Strip Slate/DnD data attributes from a cloned subtree so the drag preview is plain, inert markup. */
|
|
94
|
+
function stripSlateAttributes(el: HTMLElement) {
|
|
95
|
+
for (const attr of Array.from(el.attributes)) {
|
|
96
|
+
if (attr.name.startsWith('data-slate') || attr.name.startsWith('data-block-id')) {
|
|
97
|
+
el.removeAttribute(attr.name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
el.removeAttribute('contenteditable');
|
|
101
|
+
for (const child of Array.from(el.children)) {
|
|
102
|
+
stripSlateAttributes(child as HTMLElement);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Make a cloned subtree safe to use as an HTML5 drag image. Chrome expands the drag-image snapshot to the
|
|
107
|
+
// WHOLE VIEWPORT when the dragged element contains a `position: fixed` descendant (it's positioned against
|
|
108
|
+
// the viewport, not the element) — this is why only the code block broke: its language picker (a Base-UI
|
|
109
|
+
// Combobox) renders a hidden `position: fixed` input. We drop fixed descendants here; we also clip nested
|
|
110
|
+
// scroll containers and strip transform/filter, which can corrupt drag images too. Runs after the clone is
|
|
111
|
+
// in the DOM so getComputedStyle resolves.
|
|
112
|
+
function neutralizeDragImageClipping(root: HTMLElement) {
|
|
113
|
+
const all = [root, ...Array.from(root.querySelectorAll<HTMLElement>('*'))];
|
|
114
|
+
for (const el of all) {
|
|
115
|
+
const cs = getComputedStyle(el);
|
|
116
|
+
if (cs.position === 'fixed') {
|
|
117
|
+
el.style.display = 'none';
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (/(auto|scroll)/.test(cs.overflowX) || /(auto|scroll)/.test(cs.overflowY)) {
|
|
121
|
+
el.style.overflow = 'hidden';
|
|
122
|
+
}
|
|
123
|
+
if (cs.transform !== 'none') el.style.transform = 'none';
|
|
124
|
+
if (cs.filter !== 'none') el.style.filter = 'none';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function DragHandle({
|
|
129
|
+
dragRef,
|
|
130
|
+
previewRef,
|
|
131
|
+
}: {
|
|
132
|
+
dragRef: Ref<HTMLButtonElement>;
|
|
133
|
+
previewRef: RefObject<HTMLDivElement | null>;
|
|
134
|
+
}) {
|
|
135
|
+
const editor = useEditorRef();
|
|
136
|
+
const element = useElement();
|
|
137
|
+
return (
|
|
138
|
+
<button
|
|
139
|
+
ref={dragRef}
|
|
140
|
+
type="button"
|
|
141
|
+
title="Drag to move"
|
|
142
|
+
aria-label="Drag to move"
|
|
143
|
+
className={cn(
|
|
144
|
+
'flex h-6 w-4 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
|
|
145
|
+
'cursor-grab active:cursor-grabbing',
|
|
146
|
+
)}
|
|
147
|
+
onClick={() => editor.tf.select(element)}
|
|
148
|
+
onMouseDown={() => {
|
|
149
|
+
// Build the drag image: a clean clone of this block in the dedicated preview element. Kept at
|
|
150
|
+
// opacity-0 (rendered, so the browser can paint it for setDragImage) until the drag actually begins.
|
|
151
|
+
const preview = previewRef.current;
|
|
152
|
+
if (!preview) return;
|
|
153
|
+
preview.replaceChildren();
|
|
154
|
+
const dom = editor.api.toDOMNode(element);
|
|
155
|
+
if (!dom) return;
|
|
156
|
+
const clone = dom.cloneNode(true) as HTMLElement;
|
|
157
|
+
stripSlateAttributes(clone);
|
|
158
|
+
const wrapper = document.createElement('div');
|
|
159
|
+
wrapper.style.display = 'flow-root';
|
|
160
|
+
wrapper.append(clone);
|
|
161
|
+
preview.append(wrapper);
|
|
162
|
+
// Clip scroll containers etc. now that the clone is in the DOM (see fn) so the drag image is the block.
|
|
163
|
+
neutralizeDragImageClipping(clone);
|
|
164
|
+
preview.classList.remove('hidden');
|
|
165
|
+
preview.classList.add('opacity-0');
|
|
166
|
+
editor.setOption(DndPlugin, 'multiplePreviewRef', previewRef);
|
|
167
|
+
}}
|
|
168
|
+
data-plate-prevent-deselect
|
|
169
|
+
>
|
|
170
|
+
<HugeiconsIcon icon={GripVerticalIcon} strokeWidth={2} className="size-4" />
|
|
171
|
+
</button>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function DropLine() {
|
|
176
|
+
const { dropLine } = useDropLine();
|
|
177
|
+
if (!dropLine) return null;
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
className={cn(
|
|
181
|
+
'absolute inset-x-0 z-50 h-0.5 bg-primary/60',
|
|
182
|
+
dropLine === 'top' ? '-top-px' : '-bottom-px',
|
|
183
|
+
)}
|
|
184
|
+
contentEditable={false}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
// The block-selection overlay variant used by table cells/rows to show a selection tint. Ported from the
|
|
4
|
+
// PlateJS example (bg-brand → bg-primary for the SAENA palette). The full BlockSelection render component
|
|
5
|
+
// isn't needed here — the table renderers use this class directly via the selection hooks.
|
|
6
|
+
export const blockSelectionVariants = cva(
|
|
7
|
+
'pointer-events-none absolute inset-0 z-1 bg-primary/10 transition-opacity',
|
|
8
|
+
{
|
|
9
|
+
defaultVariants: { active: true },
|
|
10
|
+
variants: {
|
|
11
|
+
active: {
|
|
12
|
+
false: 'opacity-0',
|
|
13
|
+
true: 'opacity-100',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
);
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
|
|
2
|
+
import { Copy01Icon, Tick02Icon } from '@hugeicons/core-free-icons';
|
|
3
|
+
import { HugeiconsIcon } from '@hugeicons/react';
|
|
4
|
+
import { CodeBlockPlugin } from '@platejs/code-block/react';
|
|
5
|
+
import { Button } from '@saena-io/ui/components/button';
|
|
6
|
+
import {
|
|
7
|
+
Combobox,
|
|
8
|
+
ComboboxContent,
|
|
9
|
+
ComboboxEmpty,
|
|
10
|
+
ComboboxItem,
|
|
11
|
+
ComboboxList,
|
|
12
|
+
ComboboxTrigger,
|
|
13
|
+
ComboboxValue,
|
|
14
|
+
} from '@saena-io/ui/components/combobox';
|
|
15
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
16
|
+
import type { TElement } from 'platejs';
|
|
17
|
+
import {
|
|
18
|
+
PlateElement,
|
|
19
|
+
type PlateElementProps,
|
|
20
|
+
PlateLeaf,
|
|
21
|
+
type PlateLeafProps,
|
|
22
|
+
useEditorRef,
|
|
23
|
+
useElement,
|
|
24
|
+
useReadOnly,
|
|
25
|
+
} from 'platejs/react';
|
|
26
|
+
import * as React from 'react';
|
|
27
|
+
|
|
28
|
+
// Code block with an in-block language picker + syntax highlighting (mirrors the Plate example's flow).
|
|
29
|
+
// The block stores its language on `element.lang`; the CodeBlockPlugin's `lowlight` instance decorates each
|
|
30
|
+
// code_line with `code_syntax` leaves carrying highlight.js class names (see rich-text-editor.tsx). The
|
|
31
|
+
// CodeSyntaxLeaf below applies those classes and globals.css (`.saena-hljs`) colors them per theme.
|
|
32
|
+
|
|
33
|
+
type CodeBlockNode = TElement & { lang?: string };
|
|
34
|
+
|
|
35
|
+
type LangItem = { value: string; label: string };
|
|
36
|
+
|
|
37
|
+
// Friendly labels for the languages lowlight `common` registers; anything else falls back to a title-cased id.
|
|
38
|
+
const LANG_LABELS: Record<string, string> = {
|
|
39
|
+
bash: 'Bash',
|
|
40
|
+
c: 'C',
|
|
41
|
+
cpp: 'C++',
|
|
42
|
+
csharp: 'C#',
|
|
43
|
+
css: 'CSS',
|
|
44
|
+
diff: 'Diff',
|
|
45
|
+
go: 'Go',
|
|
46
|
+
graphql: 'GraphQL',
|
|
47
|
+
ini: 'INI / TOML',
|
|
48
|
+
java: 'Java',
|
|
49
|
+
javascript: 'JavaScript',
|
|
50
|
+
json: 'JSON',
|
|
51
|
+
kotlin: 'Kotlin',
|
|
52
|
+
less: 'Less',
|
|
53
|
+
lua: 'Lua',
|
|
54
|
+
makefile: 'Makefile',
|
|
55
|
+
markdown: 'Markdown',
|
|
56
|
+
objectivec: 'Objective-C',
|
|
57
|
+
perl: 'Perl',
|
|
58
|
+
php: 'PHP',
|
|
59
|
+
'php-template': 'PHP Template',
|
|
60
|
+
python: 'Python',
|
|
61
|
+
'python-repl': 'Python REPL',
|
|
62
|
+
r: 'R',
|
|
63
|
+
ruby: 'Ruby',
|
|
64
|
+
rust: 'Rust',
|
|
65
|
+
scss: 'SCSS',
|
|
66
|
+
shell: 'Shell',
|
|
67
|
+
sql: 'SQL',
|
|
68
|
+
swift: 'Swift',
|
|
69
|
+
typescript: 'TypeScript',
|
|
70
|
+
vbnet: 'VB.NET',
|
|
71
|
+
wasm: 'WebAssembly',
|
|
72
|
+
xml: 'HTML / XML',
|
|
73
|
+
yaml: 'YAML',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function titleCase(id: string) {
|
|
77
|
+
return id.replace(/(^|[-_])(\w)/g, (_, sep, ch) => (sep ? ' ' : '') + ch.toUpperCase());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `plaintext` / `auto` are pseudo-languages handled specially by the plugin's decorate (no highlight /
|
|
81
|
+
// highlightAuto). highlight.js registers `plaintext` itself, so exclude the reserved ids from the grammar
|
|
82
|
+
// list to avoid a duplicate option (and duplicate React key).
|
|
83
|
+
const RESERVED_LANGS = new Set(['plaintext', 'auto']);
|
|
84
|
+
|
|
85
|
+
function useLanguageItems(): LangItem[] {
|
|
86
|
+
const editor = useEditorRef();
|
|
87
|
+
return React.useMemo(() => {
|
|
88
|
+
const lowlight = editor.getOptions(CodeBlockPlugin).lowlight;
|
|
89
|
+
const langs = (lowlight ? [...lowlight.listLanguages()] : [])
|
|
90
|
+
.filter((id) => !RESERVED_LANGS.has(id))
|
|
91
|
+
.map((id) => ({ value: id, label: LANG_LABELS[id] ?? titleCase(id) }))
|
|
92
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
93
|
+
return [
|
|
94
|
+
{ value: 'plaintext', label: 'Plain text' },
|
|
95
|
+
{ value: 'auto', label: 'Auto' },
|
|
96
|
+
...langs,
|
|
97
|
+
];
|
|
98
|
+
}, [editor]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function LanguageCombobox({ element }: { element: CodeBlockNode }) {
|
|
102
|
+
const editor = useEditorRef();
|
|
103
|
+
const items = useLanguageItems();
|
|
104
|
+
const current = element.lang || 'plaintext';
|
|
105
|
+
const selected = items.find((item) => item.value === current) ?? items[0];
|
|
106
|
+
|
|
107
|
+
const setLanguage = (lang: string) => {
|
|
108
|
+
const path = editor.api.findPath(element);
|
|
109
|
+
if (!path) return;
|
|
110
|
+
editor.tf.setNodes<CodeBlockNode>({ lang }, { at: path });
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Combobox
|
|
115
|
+
items={items}
|
|
116
|
+
value={selected}
|
|
117
|
+
onValueChange={(item) => item && setLanguage((item as LangItem).value)}
|
|
118
|
+
>
|
|
119
|
+
<ComboboxTrigger
|
|
120
|
+
render={<Button variant="ghost" size="sm" />}
|
|
121
|
+
className="h-6 gap-1 px-2 font-mono text-muted-foreground text-xs hover:text-foreground"
|
|
122
|
+
>
|
|
123
|
+
<ComboboxValue>{(val: LangItem | null) => val?.label ?? 'Plain text'}</ComboboxValue>
|
|
124
|
+
</ComboboxTrigger>
|
|
125
|
+
<ComboboxContent align="end" className="w-56">
|
|
126
|
+
<div className="border-b p-1">
|
|
127
|
+
<ComboboxPrimitive.Input
|
|
128
|
+
placeholder="Search language…"
|
|
129
|
+
className="w-full rounded-sm bg-transparent px-2 py-1 text-xs outline-none placeholder:text-muted-foreground"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
<ComboboxEmpty>No language found.</ComboboxEmpty>
|
|
133
|
+
<ComboboxList>
|
|
134
|
+
{(item: LangItem) => (
|
|
135
|
+
<ComboboxItem key={item.value} value={item}>
|
|
136
|
+
{item.label}
|
|
137
|
+
</ComboboxItem>
|
|
138
|
+
)}
|
|
139
|
+
</ComboboxList>
|
|
140
|
+
</ComboboxContent>
|
|
141
|
+
</Combobox>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Copy the code block's text to the clipboard, with a brief check-mark confirmation. */
|
|
146
|
+
function CopyButton({ element }: { element: CodeBlockNode }) {
|
|
147
|
+
const [copied, setCopied] = React.useState(false);
|
|
148
|
+
const copy = () => {
|
|
149
|
+
const lines = (element.children as Array<{ children?: Array<{ text?: string }> }>) ?? [];
|
|
150
|
+
const text = lines.map((l) => (l.children ?? []).map((c) => c.text ?? '').join('')).join('\n');
|
|
151
|
+
navigator.clipboard
|
|
152
|
+
?.writeText(text)
|
|
153
|
+
.then(() => {
|
|
154
|
+
setCopied(true);
|
|
155
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
156
|
+
})
|
|
157
|
+
.catch(() => {});
|
|
158
|
+
};
|
|
159
|
+
return (
|
|
160
|
+
<Button
|
|
161
|
+
type="button"
|
|
162
|
+
variant="ghost"
|
|
163
|
+
size="icon-sm"
|
|
164
|
+
title={copied ? 'Copied' : 'Copy code'}
|
|
165
|
+
aria-label="Copy code"
|
|
166
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
167
|
+
onClick={copy}
|
|
168
|
+
>
|
|
169
|
+
<HugeiconsIcon icon={copied ? Tick02Icon : Copy01Icon} strokeWidth={2} className="size-3.5" />
|
|
170
|
+
</Button>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function CodeBlockElement(props: PlateElementProps) {
|
|
175
|
+
const { children } = props;
|
|
176
|
+
const element = useElement<CodeBlockNode>();
|
|
177
|
+
const readOnly = useReadOnly();
|
|
178
|
+
return (
|
|
179
|
+
<PlateElement
|
|
180
|
+
{...props}
|
|
181
|
+
className="group/code relative my-4 overflow-hidden rounded-lg border bg-muted/40"
|
|
182
|
+
>
|
|
183
|
+
{!readOnly && (
|
|
184
|
+
<div
|
|
185
|
+
className="absolute top-1 right-1 z-10 flex items-center gap-0.5 opacity-70 transition-opacity focus-within:opacity-100 group-hover/code:opacity-100"
|
|
186
|
+
contentEditable={false}
|
|
187
|
+
>
|
|
188
|
+
<LanguageCombobox element={element} />
|
|
189
|
+
<CopyButton element={element} />
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
<pre className="overflow-x-auto px-4 py-3 font-mono text-[13px] leading-relaxed [tab-size:2]">
|
|
193
|
+
<code className="saena-hljs">{children}</code>
|
|
194
|
+
</pre>
|
|
195
|
+
</PlateElement>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Each highlighted token arrives as a `code_syntax` decoration carrying a `className` (highlight.js scopes
|
|
200
|
+
// like `hljs-keyword`); applying it here is what makes the colors in globals.css `.saena-hljs` take effect.
|
|
201
|
+
export function CodeSyntaxLeaf(props: PlateLeafProps) {
|
|
202
|
+
const className = (props.leaf as { className?: string }).className;
|
|
203
|
+
return <PlateLeaf {...props} className={cn(className)} />;
|
|
204
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Value } from 'platejs';
|
|
2
|
+
|
|
3
|
+
// The on-the-wire storage codec for a `rich` field (ADR-0005 §5: one serialized Slate-JSON document per
|
|
4
|
+
// value). A leaf module — only the `Value` type from platejs, no editor — so BOTH the editor and the
|
|
5
|
+
// editor-free static renderer (public site) can parse/serialize a stored value without pulling the editor
|
|
6
|
+
// into the bundle. Every consumer (translations page, CMS, future plugins) agrees on this single shape.
|
|
7
|
+
// Keep parse + serialize symmetric: serializeRichText(parseRichText(s)) round-trips a well-formed value.
|
|
8
|
+
|
|
9
|
+
/** An empty document (one paragraph) — the value a fresh rich field starts from. */
|
|
10
|
+
export const emptyRichValue: Value = [{ type: 'p', children: [{ text: '' }] }];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a stored rich value (serialized Slate JSON) back into a `Value`. Empty/whitespace input and a
|
|
14
|
+
* non-array / empty parse both normalise to {@link emptyRichValue}.
|
|
15
|
+
*
|
|
16
|
+
* `plainTextFallback` (default false) controls the catch path for input that isn't valid JSON: when true,
|
|
17
|
+
* the raw string is wrapped in a single paragraph (i18n's history of storing translations as raw strings);
|
|
18
|
+
* a brand-new `rich` field with no plain-text rows should leave it off so malformed JSON surfaces.
|
|
19
|
+
*/
|
|
20
|
+
export function parseRichText(
|
|
21
|
+
text: string | undefined,
|
|
22
|
+
opts?: { plainTextFallback?: boolean },
|
|
23
|
+
): Value {
|
|
24
|
+
if (!text) return emptyRichValue;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(text);
|
|
27
|
+
return Array.isArray(parsed) && parsed.length > 0 ? (parsed as Value) : emptyRichValue;
|
|
28
|
+
} catch {
|
|
29
|
+
return opts?.plainTextFallback ? [{ type: 'p', children: [{ text }] }] : emptyRichValue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Serialize a rich `Value` to its stored string form. */
|
|
34
|
+
export function serializeRichText(value: Value): string {
|
|
35
|
+
return JSON.stringify(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Structural deep-equality of two Slate node trees, ignoring object-key order. The save dirty-check, instead
|
|
40
|
+
* of comparing JSON.stringify output (key-order-sensitive → spurious dirties → needless re-staling). Compares
|
|
41
|
+
* values not text, so it can only report fewer (never spurious) differences.
|
|
42
|
+
*/
|
|
43
|
+
function richValueEqual(a: unknown, b: unknown): boolean {
|
|
44
|
+
if (a === b) return true;
|
|
45
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
|
46
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
47
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
48
|
+
return a.every((v, i) => richValueEqual(v, b[i]));
|
|
49
|
+
}
|
|
50
|
+
const ak = Object.keys(a as object);
|
|
51
|
+
const bk = Object.keys(b as object);
|
|
52
|
+
if (ak.length !== bk.length) return false;
|
|
53
|
+
return ak.every(
|
|
54
|
+
(k) =>
|
|
55
|
+
Object.hasOwn(b as object, k) &&
|
|
56
|
+
richValueEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether `current` differs from `initial` — the save dirty-check for a rich field. */
|
|
61
|
+
export function richValueIsDirty(current: Value, initial: Value): boolean {
|
|
62
|
+
return !richValueEqual(current, initial);
|
|
63
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { AnyPlatePlugin } from 'platejs/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
// The rich-text ADDON seam (§9, ADR-0005). The editor ships a core capability set; feature packages
|
|
5
|
+
// extend it with their own Plate plugins + toolbar controls (media, text styles, AI copilot, …). An
|
|
6
|
+
// addon is authored against @saena-io/ui ONLY — the Plate surface it needs (`AnyPlatePlugin`) is
|
|
7
|
+
// re-exported from the rich-text barrel — so it never imports `platejs` directly and the plugin
|
|
8
|
+
// dependency boundary holds (plugins may import @saena-io/plugin-sdk + @saena-io/ui only; ARCHITECTURE §13).
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where an extension's toolbar control is placed. A slot names a toolbar group: contributed items are
|
|
12
|
+
* appended to that group (after its built-in controls, so those keep their positions) and sorted by
|
|
13
|
+
* `order`. `document | block | format | lists | objects` are the editor's existing groups; `ai` and `end`
|
|
14
|
+
* have no core group, so items there form their own trailing group ("AI" / "More"). Placement is by id, so
|
|
15
|
+
* an addon's button lands predictably regardless of load order, and adding a slot later never reshuffles
|
|
16
|
+
* existing controls.
|
|
17
|
+
*/
|
|
18
|
+
export type ToolbarSlot = 'document' | 'block' | 'format' | 'lists' | 'objects' | 'ai' | 'end';
|
|
19
|
+
|
|
20
|
+
/** One toolbar control, contributed by an extension. */
|
|
21
|
+
export interface ToolbarItem {
|
|
22
|
+
/** Stable id — React key + de-dupe. */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Which group it renders into (see {@link ToolbarSlot}). */
|
|
25
|
+
slot: ToolbarSlot;
|
|
26
|
+
/** Sort order within the slot; lower renders first. Leave gaps (10, 20, …) so addons can interleave. */
|
|
27
|
+
order: number;
|
|
28
|
+
/** Renders the control (its expanded form). Reads the focused editor via Plate hooks, like the core
|
|
29
|
+
* controls. When its group folds on a narrow screen the same node is shown inside the dropdown, so keep
|
|
30
|
+
* it click-only (a button / popover trigger), not an inline text field. */
|
|
31
|
+
render: () => ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A rich-text addon — the EDITOR half of a feature package. It contributes Plate plugins and toolbar
|
|
36
|
+
* controls; the host composes a list of extensions into an editor instance (see `useRichTextEditor` /
|
|
37
|
+
* `RichTextToolbar`).
|
|
38
|
+
*
|
|
39
|
+
* Server-backed capabilities (an AI completion fn, an asset uploader) are closed over when the host
|
|
40
|
+
* CONSTRUCTS the extension — e.g. `createAiCopilotExtension({ complete })` returns a `RichTextExtension`
|
|
41
|
+
* with its Plate plugin already wired to the injected fn — so those dependencies never flow through
|
|
42
|
+
* @saena-io/ui. The matching SERVER half (the completion/upload endpoint) is a separate
|
|
43
|
+
* `@saena-io/plugin-sdk` Plugin. (Static renderers for the public-site bundle attach here when that path
|
|
44
|
+
* lands — ADR-0005.)
|
|
45
|
+
*/
|
|
46
|
+
export interface RichTextExtension {
|
|
47
|
+
/** Namespaced id, e.g. 'media' | 'text-style' | 'ai'. */
|
|
48
|
+
id: string;
|
|
49
|
+
/** Plate plugins appended to the core editor set. */
|
|
50
|
+
platePlugins?: AnyPlatePlugin[];
|
|
51
|
+
/** Toolbar controls placed into slots. */
|
|
52
|
+
toolbar?: ToolbarItem[];
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CopilotPlugin } from '@platejs/ai/react';
|
|
2
|
+
import { useEditorPlugin, usePluginOption } from 'platejs/react';
|
|
3
|
+
|
|
4
|
+
// Ghost-text renderer for the AI copilot (ADR-0010). Shows the current block's pending AI suggestion inline
|
|
5
|
+
// (muted, non-editable); Tab accepts, Esc rejects (wired by CopilotPlugin's shortcuts). Vendored here — Plate
|
|
6
|
+
// ships this as a registry component the consumer owns, so it lives in @saena-io/ui beside the editor.
|
|
7
|
+
export function GhostText() {
|
|
8
|
+
const { editor } = useEditorPlugin(CopilotPlugin);
|
|
9
|
+
const blockId = (editor.api.block({ highest: true })?.[0] as { id?: string } | undefined)?.id;
|
|
10
|
+
const isSuggested = usePluginOption(CopilotPlugin, 'isSuggested', blockId as string);
|
|
11
|
+
if (!isSuggested) return null;
|
|
12
|
+
return <GhostTextContent />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function GhostTextContent() {
|
|
16
|
+
const suggestionText = usePluginOption(CopilotPlugin, 'suggestionText');
|
|
17
|
+
if (!suggestionText) return null;
|
|
18
|
+
return (
|
|
19
|
+
<span className="text-muted-foreground/70 max-sm:hidden" contentEditable={false}>
|
|
20
|
+
{suggestionText}
|
|
21
|
+
</span>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { MarkdownPlugin } from '@platejs/markdown';
|
|
2
|
+
import { getEditorDOMFromHtmlString, stripSlateDataAttributes } from 'platejs/static';
|
|
3
|
+
import type { RichTextEditor } from './plugins';
|
|
4
|
+
|
|
5
|
+
// Document import / export ACTIONS (Markdown, HTML, Word). Exposed as item builders so the toolbar can
|
|
6
|
+
// drop them into a group that miniaturises — import reads a file via a host-provided picker (one hidden
|
|
7
|
+
// input lives in the toolbar), export serializes the focused pane and downloads. Markdown round-trips
|
|
8
|
+
// through MarkdownPlugin (GFM); HTML via editor.api.html + a clean-up of the rendered DOM; Word via docx-io.
|
|
9
|
+
//
|
|
10
|
+
// `@platejs/docx-io` is imported DYNAMICALLY inside the two Word handlers, never at module scope: it pulls
|
|
11
|
+
// the CJS `virtual-dom` package, which Vite's ESM SSR can't resolve (throws "Cannot find module
|
|
12
|
+
// virtual-dom/vnode/vnode"). A top-level import would drag that into the SSR module graph and crash any
|
|
13
|
+
// server-rendered route that mounts the editor — even when Word import/export is never used. Deferring it
|
|
14
|
+
// to the click keeps the editor's load graph SSR-safe (and trims it from the initial client bundle).
|
|
15
|
+
|
|
16
|
+
function download(href: string, filename: string) {
|
|
17
|
+
const link = document.createElement('a');
|
|
18
|
+
link.href = href;
|
|
19
|
+
link.download = filename;
|
|
20
|
+
document.body.append(link);
|
|
21
|
+
link.click();
|
|
22
|
+
link.remove();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function textHref(mime: string, text: string) {
|
|
26
|
+
return `data:${mime};charset=utf-8,${encodeURIComponent(text)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type AnyNodes = Parameters<RichTextEditor['tf']['insertNodes']>[0];
|
|
30
|
+
|
|
31
|
+
/** One import/export menu entry. */
|
|
32
|
+
export interface DocItem {
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
run: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Import entries — each opens the host's file picker with the right `accept` + parse handler. */
|
|
39
|
+
export function importMenuItems(
|
|
40
|
+
editor: RichTextEditor | null,
|
|
41
|
+
pick: (accept: string, handler: (file: File) => void | Promise<void>) => void,
|
|
42
|
+
): DocItem[] {
|
|
43
|
+
const importHtml = async (file: File) => {
|
|
44
|
+
if (!editor) return;
|
|
45
|
+
const element = getEditorDOMFromHtmlString(await file.text());
|
|
46
|
+
editor.tf.insertNodes(editor.api.html.deserialize({ element }) as AnyNodes);
|
|
47
|
+
};
|
|
48
|
+
const importMarkdown = async (file: File) => {
|
|
49
|
+
if (!editor) return;
|
|
50
|
+
editor.tf.insertNodes(
|
|
51
|
+
editor.getApi(MarkdownPlugin).markdown.deserialize(await file.text()) as AnyNodes,
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
const importWord = async (file: File) => {
|
|
55
|
+
if (!editor) return;
|
|
56
|
+
const { importDocx } = await import('@platejs/docx-io');
|
|
57
|
+
const result = await importDocx(editor, await file.arrayBuffer());
|
|
58
|
+
editor.tf.insertNodes(result.nodes as AnyNodes);
|
|
59
|
+
};
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
id: 'import-html',
|
|
63
|
+
label: 'Import from HTML',
|
|
64
|
+
run: () => pick('text/html,.html', importHtml),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'import-markdown',
|
|
68
|
+
label: 'Import from Markdown',
|
|
69
|
+
run: () => pick('.md,.mdx,text/markdown', importMarkdown),
|
|
70
|
+
},
|
|
71
|
+
{ id: 'import-word', label: 'Import from Word', run: () => pick('.docx', importWord) },
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Export entries — serialize the focused pane and download. */
|
|
76
|
+
export function exportMenuItems(editor: RichTextEditor | null): DocItem[] {
|
|
77
|
+
const exportMarkdown = () => {
|
|
78
|
+
if (!editor) return;
|
|
79
|
+
const md = editor.getApi(MarkdownPlugin).markdown.serialize();
|
|
80
|
+
download(textHref('text/markdown', md), 'translation.md');
|
|
81
|
+
};
|
|
82
|
+
const exportHtml = () => {
|
|
83
|
+
if (!editor) return;
|
|
84
|
+
// Serialize the rendered editor DOM and strip Slate's internal attributes, for clean semantic HTML.
|
|
85
|
+
const dom = editor.api.toDOMNode(editor);
|
|
86
|
+
const body = stripSlateDataAttributes(dom?.innerHTML ?? '');
|
|
87
|
+
const html = `<!DOCTYPE html>\n<html>\n<head><meta charset="utf-8" /></head>\n<body>\n${body}\n</body>\n</html>`;
|
|
88
|
+
download(textHref('text/html', html), 'translation.html');
|
|
89
|
+
};
|
|
90
|
+
const exportWord = async () => {
|
|
91
|
+
if (!editor) return;
|
|
92
|
+
const { exportToDocx } = await import('@platejs/docx-io');
|
|
93
|
+
const blob = await exportToDocx(editor.children);
|
|
94
|
+
const url = URL.createObjectURL(blob);
|
|
95
|
+
download(url, 'translation.docx');
|
|
96
|
+
URL.revokeObjectURL(url);
|
|
97
|
+
};
|
|
98
|
+
return [
|
|
99
|
+
{ id: 'export-markdown', label: 'Export as Markdown', run: exportMarkdown },
|
|
100
|
+
{ id: 'export-html', label: 'Export as HTML', run: exportHtml },
|
|
101
|
+
{ id: 'export-word', label: 'Export as Word', run: () => void exportWord() },
|
|
102
|
+
];
|
|
103
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getLinkAttributes } from '@platejs/link';
|
|
2
|
+
import type { TLinkElement } from 'platejs';
|
|
3
|
+
import { PlateElement, type PlateElementProps } from 'platejs/react';
|
|
4
|
+
|
|
5
|
+
// Inline link node renderer. The URL lives on the node (element.url) and round-trips through the stored
|
|
6
|
+
// Slate JSON. We spread getLinkAttributes(editor, element) so the editor <a> carries a real, sanitized
|
|
7
|
+
// href/target (cmd/middle-click, copy-link, hover-preview all work). Add/edit/remove is driven from the
|
|
8
|
+
// toolbar's Link popover (upsertLink / unwrapLink).
|
|
9
|
+
export function LinkElement(props: PlateElementProps<TLinkElement>) {
|
|
10
|
+
return (
|
|
11
|
+
<PlateElement
|
|
12
|
+
{...props}
|
|
13
|
+
as="a"
|
|
14
|
+
className="cursor-pointer font-medium text-primary underline underline-offset-2"
|
|
15
|
+
attributes={{ ...props.attributes, ...getLinkAttributes(props.editor, props.element) }}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|