@modern-admin/ui 0.1.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/components/accordion.d.ts +7 -0
- package/dist/components/accordion.d.ts.map +1 -0
- package/dist/components/accordion.jsx +19 -0
- package/dist/components/accordion.jsx.map +1 -0
- package/dist/components/alert-dialog.d.ts +22 -0
- package/dist/components/alert-dialog.d.ts.map +1 -0
- package/dist/components/alert-dialog.jsx +27 -0
- package/dist/components/alert-dialog.jsx.map +1 -0
- package/dist/components/audit-timeline.d.ts +24 -0
- package/dist/components/audit-timeline.d.ts.map +1 -0
- package/dist/components/audit-timeline.jsx +60 -0
- package/dist/components/audit-timeline.jsx.map +1 -0
- package/dist/components/avatar.d.ts +6 -0
- package/dist/components/avatar.d.ts.map +1 -0
- package/dist/components/avatar.jsx +10 -0
- package/dist/components/avatar.jsx.map +1 -0
- package/dist/components/badge.d.ts +10 -0
- package/dist/components/badge.d.ts.map +1 -0
- package/dist/components/badge.jsx +19 -0
- package/dist/components/badge.jsx.map +1 -0
- package/dist/components/breadcrumb.d.ts +17 -0
- package/dist/components/breadcrumb.d.ts.map +1 -0
- package/dist/components/breadcrumb.jsx +27 -0
- package/dist/components/breadcrumb.jsx.map +1 -0
- package/dist/components/button.d.ts +12 -0
- package/dist/components/button.d.ts.map +1 -0
- package/dist/components/button.jsx +37 -0
- package/dist/components/button.jsx.map +1 -0
- package/dist/components/calendar.d.ts +9 -0
- package/dist/components/calendar.d.ts.map +1 -0
- package/dist/components/calendar.jsx +102 -0
- package/dist/components/calendar.jsx.map +1 -0
- package/dist/components/card.d.ts +8 -0
- package/dist/components/card.d.ts.map +1 -0
- package/dist/components/card.jsx +18 -0
- package/dist/components/card.jsx.map +1 -0
- package/dist/components/chart.d.ts +97 -0
- package/dist/components/chart.d.ts.map +1 -0
- package/dist/components/chart.jsx +233 -0
- package/dist/components/chart.jsx.map +1 -0
- package/dist/components/checkbox.d.ts +4 -0
- package/dist/components/checkbox.d.ts.map +1 -0
- package/dist/components/checkbox.jsx +11 -0
- package/dist/components/checkbox.jsx.map +1 -0
- package/dist/components/combobox.d.ts +46 -0
- package/dist/components/combobox.d.ts.map +1 -0
- package/dist/components/combobox.jsx +145 -0
- package/dist/components/combobox.jsx.map +1 -0
- package/dist/components/command.d.ts +80 -0
- package/dist/components/command.d.ts.map +1 -0
- package/dist/components/command.jsx +32 -0
- package/dist/components/command.jsx.map +1 -0
- package/dist/components/date-picker.d.ts +24 -0
- package/dist/components/date-picker.d.ts.map +1 -0
- package/dist/components/date-picker.jsx +149 -0
- package/dist/components/date-picker.jsx.map +1 -0
- package/dist/components/date-range-input.d.ts +22 -0
- package/dist/components/date-range-input.d.ts.map +1 -0
- package/dist/components/date-range-input.jsx +202 -0
- package/dist/components/date-range-input.jsx.map +1 -0
- package/dist/components/dialog.d.ts +19 -0
- package/dist/components/dialog.d.ts.map +1 -0
- package/dist/components/dialog.jsx +30 -0
- package/dist/components/dialog.jsx.map +1 -0
- package/dist/components/diff-view.d.ts +24 -0
- package/dist/components/diff-view.d.ts.map +1 -0
- package/dist/components/diff-view.jsx +69 -0
- package/dist/components/diff-view.jsx.map +1 -0
- package/dist/components/dropdown-menu.d.ts +27 -0
- package/dist/components/dropdown-menu.d.ts.map +1 -0
- package/dist/components/dropdown-menu.jsx +48 -0
- package/dist/components/dropdown-menu.jsx.map +1 -0
- package/dist/components/empty.d.ts +15 -0
- package/dist/components/empty.d.ts.map +1 -0
- package/dist/components/empty.jsx +27 -0
- package/dist/components/empty.jsx.map +1 -0
- package/dist/components/field.d.ts +23 -0
- package/dist/components/field.d.ts.map +1 -0
- package/dist/components/field.jsx +60 -0
- package/dist/components/field.jsx.map +1 -0
- package/dist/components/file-input.d.ts +50 -0
- package/dist/components/file-input.d.ts.map +1 -0
- package/dist/components/file-input.jsx +104 -0
- package/dist/components/file-input.jsx.map +1 -0
- package/dist/components/form.d.ts +20 -0
- package/dist/components/form.d.ts.map +1 -0
- package/dist/components/form.jsx +66 -0
- package/dist/components/form.jsx.map +1 -0
- package/dist/components/info-tooltip.d.ts +11 -0
- package/dist/components/info-tooltip.d.ts.map +1 -0
- package/dist/components/info-tooltip.jsx +17 -0
- package/dist/components/info-tooltip.jsx.map +1 -0
- package/dist/components/input.d.ts +13 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.jsx +19 -0
- package/dist/components/input.jsx.map +1 -0
- package/dist/components/json-editor.d.ts +23 -0
- package/dist/components/json-editor.d.ts.map +1 -0
- package/dist/components/json-editor.jsx +143 -0
- package/dist/components/json-editor.jsx.map +1 -0
- package/dist/components/kbd.d.ts +15 -0
- package/dist/components/kbd.d.ts.map +1 -0
- package/dist/components/kbd.jsx +23 -0
- package/dist/components/kbd.jsx.map +1 -0
- package/dist/components/key-value-editor.d.ts +92 -0
- package/dist/components/key-value-editor.d.ts.map +1 -0
- package/dist/components/key-value-editor.jsx +187 -0
- package/dist/components/key-value-editor.jsx.map +1 -0
- package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
- package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
- package/dist/components/keyboard-shortcuts-help.jsx +97 -0
- package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
- package/dist/components/label.d.ts +5 -0
- package/dist/components/label.d.ts.map +1 -0
- package/dist/components/label.jsx +8 -0
- package/dist/components/label.jsx.map +1 -0
- package/dist/components/media-preview.d.ts +30 -0
- package/dist/components/media-preview.d.ts.map +1 -0
- package/dist/components/media-preview.jsx +189 -0
- package/dist/components/media-preview.jsx.map +1 -0
- package/dist/components/multi-file-input.d.ts +76 -0
- package/dist/components/multi-file-input.d.ts.map +1 -0
- package/dist/components/multi-file-input.jsx +131 -0
- package/dist/components/multi-file-input.jsx.map +1 -0
- package/dist/components/password-input.d.ts +10 -0
- package/dist/components/password-input.d.ts.map +1 -0
- package/dist/components/password-input.jsx +18 -0
- package/dist/components/password-input.jsx.map +1 -0
- package/dist/components/popover.d.ts +7 -0
- package/dist/components/popover.d.ts.map +1 -0
- package/dist/components/popover.jsx +11 -0
- package/dist/components/popover.jsx.map +1 -0
- package/dist/components/revision-timeline.d.ts +30 -0
- package/dist/components/revision-timeline.d.ts.map +1 -0
- package/dist/components/revision-timeline.jsx +42 -0
- package/dist/components/revision-timeline.jsx.map +1 -0
- package/dist/components/richtext-editor.d.ts +43 -0
- package/dist/components/richtext-editor.d.ts.map +1 -0
- package/dist/components/richtext-editor.jsx +319 -0
- package/dist/components/richtext-editor.jsx.map +1 -0
- package/dist/components/richtext-mode.d.ts +23 -0
- package/dist/components/richtext-mode.d.ts.map +1 -0
- package/dist/components/richtext-mode.js +36 -0
- package/dist/components/richtext-mode.js.map +1 -0
- package/dist/components/richtext-render.d.ts +8 -0
- package/dist/components/richtext-render.d.ts.map +1 -0
- package/dist/components/richtext-render.jsx +33 -0
- package/dist/components/richtext-render.jsx.map +1 -0
- package/dist/components/richtext-sync.d.ts +37 -0
- package/dist/components/richtext-sync.d.ts.map +1 -0
- package/dist/components/richtext-sync.js +46 -0
- package/dist/components/richtext-sync.js.map +1 -0
- package/dist/components/scroll-area.d.ts +5 -0
- package/dist/components/scroll-area.d.ts.map +1 -0
- package/dist/components/scroll-area.jsx +16 -0
- package/dist/components/scroll-area.jsx.map +1 -0
- package/dist/components/select.d.ts +36 -0
- package/dist/components/select.d.ts.map +1 -0
- package/dist/components/select.jsx +87 -0
- package/dist/components/select.jsx.map +1 -0
- package/dist/components/separator.d.ts +4 -0
- package/dist/components/separator.d.ts.map +1 -0
- package/dist/components/separator.jsx +6 -0
- package/dist/components/separator.jsx.map +1 -0
- package/dist/components/sheet.d.ts +29 -0
- package/dist/components/sheet.d.ts.map +1 -0
- package/dist/components/sheet.jsx +44 -0
- package/dist/components/sheet.jsx.map +1 -0
- package/dist/components/sidebar.d.ts +70 -0
- package/dist/components/sidebar.d.ts.map +1 -0
- package/dist/components/sidebar.jsx +245 -0
- package/dist/components/sidebar.jsx.map +1 -0
- package/dist/components/skeleton.d.ts +3 -0
- package/dist/components/skeleton.d.ts.map +1 -0
- package/dist/components/skeleton.jsx +6 -0
- package/dist/components/skeleton.jsx.map +1 -0
- package/dist/components/sonner.d.ts +6 -0
- package/dist/components/sonner.d.ts.map +1 -0
- package/dist/components/sonner.jsx +29 -0
- package/dist/components/sonner.jsx.map +1 -0
- package/dist/components/switch.d.ts +4 -0
- package/dist/components/switch.d.ts.map +1 -0
- package/dist/components/switch.jsx +8 -0
- package/dist/components/switch.jsx.map +1 -0
- package/dist/components/table.d.ts +10 -0
- package/dist/components/table.d.ts.map +1 -0
- package/dist/components/table.jsx +21 -0
- package/dist/components/table.jsx.map +1 -0
- package/dist/components/tabs.d.ts +7 -0
- package/dist/components/tabs.d.ts.map +1 -0
- package/dist/components/tabs.jsx +14 -0
- package/dist/components/tabs.jsx.map +1 -0
- package/dist/components/textarea.d.ts +4 -0
- package/dist/components/textarea.d.ts.map +1 -0
- package/dist/components/textarea.jsx +5 -0
- package/dist/components/textarea.jsx.map +1 -0
- package/dist/components/tooltip.d.ts +7 -0
- package/dist/components/tooltip.d.ts.map +1 -0
- package/dist/components/tooltip.jsx +11 -0
- package/dist/components/tooltip.jsx.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/theme.d.ts +11 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +44 -0
- package/dist/lib/theme.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/styles.css +242 -0
- package/package.json +85 -0
- package/src/components/accordion.tsx +48 -0
- package/src/components/alert-dialog.tsx +113 -0
- package/src/components/audit-timeline.tsx +102 -0
- package/src/components/avatar.tsx +42 -0
- package/src/components/badge.tsx +34 -0
- package/src/components/breadcrumb.tsx +99 -0
- package/src/components/button.tsx +58 -0
- package/src/components/calendar.tsx +176 -0
- package/src/components/card.tsx +60 -0
- package/src/components/chart.tsx +558 -0
- package/src/components/checkbox.tsx +23 -0
- package/src/components/combobox.tsx +264 -0
- package/src/components/command.tsx +120 -0
- package/src/components/date-picker.tsx +221 -0
- package/src/components/date-range-input.tsx +295 -0
- package/src/components/dialog.tsx +94 -0
- package/src/components/diff-view.tsx +182 -0
- package/src/components/dropdown-menu.tsx +165 -0
- package/src/components/empty.tsx +100 -0
- package/src/components/field.tsx +168 -0
- package/src/components/file-input.tsx +233 -0
- package/src/components/form.tsx +152 -0
- package/src/components/info-tooltip.tsx +40 -0
- package/src/components/input.tsx +55 -0
- package/src/components/json-editor.tsx +210 -0
- package/src/components/kbd.tsx +35 -0
- package/src/components/key-value-editor.tsx +423 -0
- package/src/components/keyboard-shortcuts-help.tsx +136 -0
- package/src/components/label.tsx +16 -0
- package/src/components/media-preview.tsx +278 -0
- package/src/components/multi-file-input.tsx +315 -0
- package/src/components/password-input.tsx +50 -0
- package/src/components/popover.tsx +26 -0
- package/src/components/revision-timeline.tsx +93 -0
- package/src/components/richtext-editor.tsx +624 -0
- package/src/components/richtext-mode.ts +39 -0
- package/src/components/richtext-render.tsx +51 -0
- package/src/components/richtext-sync.ts +57 -0
- package/src/components/scroll-area.tsx +41 -0
- package/src/components/select.tsx +200 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +109 -0
- package/src/components/sidebar.tsx +660 -0
- package/src/components/skeleton.tsx +9 -0
- package/src/components/sonner.tsx +45 -0
- package/src/components/switch.tsx +24 -0
- package/src/components/table.tsx +93 -0
- package/src/components/tabs.tsx +57 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/tooltip.tsx +25 -0
- package/src/index.ts +342 -0
- package/src/lib/theme.ts +45 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +242 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
// Tiptap-3-based WYSIWYG editor with a Source/Plain toggle.
|
|
2
|
+
//
|
|
3
|
+
// <RichtextEditor value={value} onChange={onChange} format="html" />
|
|
4
|
+
//
|
|
5
|
+
// `format` controls both the I/O contract (HTML vs Markdown) and the
|
|
6
|
+
// content of the Source view: when format='markdown' the editor uses the
|
|
7
|
+
// `tiptap-markdown` extension and `value` is the raw Markdown string;
|
|
8
|
+
// when format='html' the value is the editor's serialised HTML.
|
|
9
|
+
|
|
10
|
+
import * as React from 'react'
|
|
11
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
12
|
+
import { StarterKit } from '@tiptap/starter-kit'
|
|
13
|
+
import { Markdown } from 'tiptap-markdown'
|
|
14
|
+
import {
|
|
15
|
+
Bold,
|
|
16
|
+
Code,
|
|
17
|
+
Code2,
|
|
18
|
+
Columns2,
|
|
19
|
+
Eye,
|
|
20
|
+
Heading1,
|
|
21
|
+
Heading2,
|
|
22
|
+
Heading3,
|
|
23
|
+
Italic,
|
|
24
|
+
Link as LinkIcon,
|
|
25
|
+
List,
|
|
26
|
+
ListOrdered,
|
|
27
|
+
Maximize2,
|
|
28
|
+
Minimize2,
|
|
29
|
+
Minus,
|
|
30
|
+
Quote,
|
|
31
|
+
Redo2,
|
|
32
|
+
Strikethrough,
|
|
33
|
+
Undo2,
|
|
34
|
+
} from 'lucide-react'
|
|
35
|
+
import { cn } from '../lib/utils.js'
|
|
36
|
+
import { Button } from './button.js'
|
|
37
|
+
import {
|
|
38
|
+
isSplitAvailable,
|
|
39
|
+
resolveMode,
|
|
40
|
+
shouldSyncEditor,
|
|
41
|
+
type RichtextMode,
|
|
42
|
+
} from './richtext-mode.js'
|
|
43
|
+
import { readEditorContent, shouldSyncToEditor } from './richtext-sync.js'
|
|
44
|
+
import { Separator } from './separator.js'
|
|
45
|
+
import { Textarea } from './textarea.js'
|
|
46
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip.js'
|
|
47
|
+
|
|
48
|
+
export type RichtextFormat = 'html' | 'markdown'
|
|
49
|
+
|
|
50
|
+
export interface RichtextEditorLabels {
|
|
51
|
+
bold?: string
|
|
52
|
+
italic?: string
|
|
53
|
+
strikethrough?: string
|
|
54
|
+
inlineCode?: string
|
|
55
|
+
/** Template — `{level}` is replaced with 1/2/3. Default: "Heading {level}". */
|
|
56
|
+
heading?: string
|
|
57
|
+
bulletList?: string
|
|
58
|
+
numberedList?: string
|
|
59
|
+
blockquote?: string
|
|
60
|
+
horizontalRule?: string
|
|
61
|
+
insertLink?: string
|
|
62
|
+
undo?: string
|
|
63
|
+
redo?: string
|
|
64
|
+
/** Template — `{format}` is replaced with "html"/"md". Default: "Source ({format})". */
|
|
65
|
+
source?: string
|
|
66
|
+
splitView?: string
|
|
67
|
+
visualEditor?: string
|
|
68
|
+
fullscreen?: string
|
|
69
|
+
exitFullscreen?: string
|
|
70
|
+
/** Prompt text for link URL input. Default: "URL". */
|
|
71
|
+
urlPrompt?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RichtextEditorProps {
|
|
75
|
+
value: string
|
|
76
|
+
onChange(value: string): void
|
|
77
|
+
format?: RichtextFormat
|
|
78
|
+
placeholder?: string
|
|
79
|
+
disabled?: boolean
|
|
80
|
+
/** Initial mode of the editor. Defaults to 'wysiwyg'. */
|
|
81
|
+
defaultMode?: RichtextMode
|
|
82
|
+
className?: string
|
|
83
|
+
/** Called when the editor blurs. Useful for RHF onBlur. */
|
|
84
|
+
onBlur?(): void
|
|
85
|
+
ariaLabelledBy?: string
|
|
86
|
+
/** Translated toolbar labels. All optional — English strings are the defaults. */
|
|
87
|
+
labels?: RichtextEditorLabels
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const HEADING_ICON: Record<1 | 2 | 3, React.ComponentType<{ className?: string }>> = {
|
|
91
|
+
1: Heading1,
|
|
92
|
+
2: Heading2,
|
|
93
|
+
3: Heading3,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const proseContentClass =
|
|
97
|
+
'prose prose-sm max-w-none min-h-[160px] p-3 text-foreground focus:outline-none ' +
|
|
98
|
+
'prose-headings:scroll-mt-20 prose-headings:text-foreground prose-p:text-foreground ' +
|
|
99
|
+
'prose-strong:text-foreground prose-li:text-foreground prose-code:text-foreground ' +
|
|
100
|
+
'prose-pre:text-foreground prose-pre:bg-muted prose-blockquote:text-muted-foreground ' +
|
|
101
|
+
'prose-blockquote:border-border prose-a:text-foreground dark:prose-invert'
|
|
102
|
+
|
|
103
|
+
function ToolbarButton({
|
|
104
|
+
active,
|
|
105
|
+
disabled,
|
|
106
|
+
onClick,
|
|
107
|
+
title,
|
|
108
|
+
children,
|
|
109
|
+
}: {
|
|
110
|
+
active?: boolean
|
|
111
|
+
disabled?: boolean
|
|
112
|
+
onClick(): void
|
|
113
|
+
title: string
|
|
114
|
+
children: React.ReactNode
|
|
115
|
+
}): React.ReactElement {
|
|
116
|
+
return (
|
|
117
|
+
<Tooltip>
|
|
118
|
+
<TooltipTrigger asChild>
|
|
119
|
+
<Button
|
|
120
|
+
type="button"
|
|
121
|
+
variant={active ? 'secondary' : 'ghost'}
|
|
122
|
+
size="icon"
|
|
123
|
+
className="size-8"
|
|
124
|
+
aria-pressed={active}
|
|
125
|
+
aria-label={title}
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
onMouseDown={(e) => {
|
|
128
|
+
// Prevent blur of the editor selection
|
|
129
|
+
e.preventDefault()
|
|
130
|
+
}}
|
|
131
|
+
onClick={onClick}
|
|
132
|
+
>
|
|
133
|
+
{children}
|
|
134
|
+
</Button>
|
|
135
|
+
</TooltipTrigger>
|
|
136
|
+
<TooltipContent>{title}</TooltipContent>
|
|
137
|
+
</Tooltip>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
export function RichtextEditor({
|
|
143
|
+
value,
|
|
144
|
+
onChange,
|
|
145
|
+
format = 'html',
|
|
146
|
+
placeholder,
|
|
147
|
+
disabled,
|
|
148
|
+
defaultMode = 'wysiwyg',
|
|
149
|
+
className,
|
|
150
|
+
onBlur,
|
|
151
|
+
ariaLabelledBy,
|
|
152
|
+
labels,
|
|
153
|
+
}: RichtextEditorProps): React.ReactElement {
|
|
154
|
+
const l = {
|
|
155
|
+
bold: labels?.bold ?? 'Bold',
|
|
156
|
+
italic: labels?.italic ?? 'Italic',
|
|
157
|
+
strikethrough: labels?.strikethrough ?? 'Strikethrough',
|
|
158
|
+
inlineCode: labels?.inlineCode ?? 'Inline code',
|
|
159
|
+
heading: labels?.heading ?? 'Heading {level}',
|
|
160
|
+
bulletList: labels?.bulletList ?? 'Bullet list',
|
|
161
|
+
numberedList: labels?.numberedList ?? 'Numbered list',
|
|
162
|
+
blockquote: labels?.blockquote ?? 'Blockquote',
|
|
163
|
+
horizontalRule: labels?.horizontalRule ?? 'Horizontal rule',
|
|
164
|
+
insertLink: labels?.insertLink ?? 'Insert link',
|
|
165
|
+
undo: labels?.undo ?? 'Undo',
|
|
166
|
+
redo: labels?.redo ?? 'Redo',
|
|
167
|
+
source: labels?.source ?? 'Source ({format})',
|
|
168
|
+
splitView: labels?.splitView ?? 'Split view',
|
|
169
|
+
visualEditor: labels?.visualEditor ?? 'Visual editor',
|
|
170
|
+
fullscreen: labels?.fullscreen ?? 'Fullscreen',
|
|
171
|
+
exitFullscreen: labels?.exitFullscreen ?? 'Exit fullscreen',
|
|
172
|
+
urlPrompt: labels?.urlPrompt ?? 'URL',
|
|
173
|
+
}
|
|
174
|
+
const [mode, setMode] = React.useState<RichtextMode>(defaultMode)
|
|
175
|
+
const [fullscreen, setFullscreen] = React.useState(false)
|
|
176
|
+
// Effective mode: collapses 'split' to 'wysiwyg' when not fullscreen so the
|
|
177
|
+
// layout stays usable. The user's chosen mode is preserved in `mode` so that
|
|
178
|
+
// re-entering fullscreen restores split.
|
|
179
|
+
const effectiveMode = resolveMode(mode, fullscreen)
|
|
180
|
+
// Currently active output format. Initial value comes from `format` prop;
|
|
181
|
+
// user can override via the toolbar HTML/MD switch. The prop acts as the
|
|
182
|
+
// controlled default — when it changes externally, sync local state.
|
|
183
|
+
const [activeFormat, setActiveFormat] = React.useState<RichtextFormat>(format)
|
|
184
|
+
React.useEffect(() => {
|
|
185
|
+
setActiveFormat(format)
|
|
186
|
+
}, [format])
|
|
187
|
+
|
|
188
|
+
// Stable ref so onUpdate (registered once with the editor) always reads the
|
|
189
|
+
// latest active format without recreating the editor instance.
|
|
190
|
+
const activeFormatRef = React.useRef(activeFormat)
|
|
191
|
+
React.useEffect(() => {
|
|
192
|
+
activeFormatRef.current = activeFormat
|
|
193
|
+
}, [activeFormat])
|
|
194
|
+
|
|
195
|
+
// Set to true inside handleFormatChange and cleared after the first sync
|
|
196
|
+
// effect run. Guards against the intermediate render where activeFormat has
|
|
197
|
+
// already changed to the new format but the external `value` prop still
|
|
198
|
+
// holds the previous format's string (parent hasn't re-rendered yet).
|
|
199
|
+
// Without this guard, the stale value would be written into the editor in
|
|
200
|
+
// the wrong format, corrupting the content.
|
|
201
|
+
const pendingFormatChangeRef = React.useRef(false)
|
|
202
|
+
|
|
203
|
+
// Tracks the most recent string we emitted via onChange. The sync effect
|
|
204
|
+
// uses it to detect "echoes" — frames where parent's value caught up to a
|
|
205
|
+
// previous onChange while the user has *already* typed more characters. In
|
|
206
|
+
// that frame editor.getHTML() is ahead of value; without echo-suppression
|
|
207
|
+
// we'd call setContent(value), reverting the editor to the stale string,
|
|
208
|
+
// dropping just-typed characters and jumping the cursor. See the
|
|
209
|
+
// "echo-suppression" tests in richtext-sync.test.ts for the full timeline.
|
|
210
|
+
const lastEmittedRef = React.useRef(value)
|
|
211
|
+
|
|
212
|
+
// Markdown extension is always loaded so the editor can produce both HTML
|
|
213
|
+
// and Markdown on demand without a costly remount when the user toggles.
|
|
214
|
+
const extensions = React.useMemo(
|
|
215
|
+
() => [
|
|
216
|
+
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
|
217
|
+
// html: true (the library default) is required so that MarkdownIt
|
|
218
|
+
// recognises HTML blocks when setContent() is called with an HTML string
|
|
219
|
+
// (format='html'). With html:false, tags like <p>/<h2>/<strong> are
|
|
220
|
+
// treated as literal characters and displayed as raw text instead of
|
|
221
|
+
// being rendered. The markdown *output* (getMarkdown) is still clean
|
|
222
|
+
// markdown for all standard nodes — html:true only affects input parsing.
|
|
223
|
+
Markdown.configure({ html: true, transformPastedText: true }) as never,
|
|
224
|
+
],
|
|
225
|
+
[],
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
const editor = useEditor(
|
|
229
|
+
{
|
|
230
|
+
extensions,
|
|
231
|
+
content: value,
|
|
232
|
+
editable: !disabled,
|
|
233
|
+
editorProps: {
|
|
234
|
+
attributes: {
|
|
235
|
+
class: cn(
|
|
236
|
+
proseContentClass,
|
|
237
|
+
),
|
|
238
|
+
'aria-labelledby': ariaLabelledBy ?? '',
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
onUpdate: ({ editor: ed }) => {
|
|
242
|
+
const next = readEditorContent(ed, activeFormatRef.current)
|
|
243
|
+
lastEmittedRef.current = next
|
|
244
|
+
onChange(next)
|
|
245
|
+
},
|
|
246
|
+
onBlur: () => onBlur?.(),
|
|
247
|
+
// Tiptap 3 ships with default content sync; no need for autofocus etc.
|
|
248
|
+
immediatelyRender: false,
|
|
249
|
+
},
|
|
250
|
+
[disabled, ariaLabelledBy],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
// Keep editor in sync when external `value` changes (e.g. form reset).
|
|
254
|
+
// shouldSyncToEditor guards against the intermediate render that follows a
|
|
255
|
+
// format switch: activeFormat has changed but value hasn't caught up yet.
|
|
256
|
+
// In that transient frame, calling setContent with the stale value would
|
|
257
|
+
// write the old format's string into the editor in the new format mode,
|
|
258
|
+
// corrupting the content. pendingFormatChangeRef is set in handleFormatChange
|
|
259
|
+
// and cleared here after the first effect invocation.
|
|
260
|
+
React.useEffect(() => {
|
|
261
|
+
if (!editor || !shouldSyncEditor(effectiveMode)) {
|
|
262
|
+
pendingFormatChangeRef.current = false
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (
|
|
266
|
+
shouldSyncToEditor(
|
|
267
|
+
readEditorContent(editor, activeFormat),
|
|
268
|
+
value,
|
|
269
|
+
pendingFormatChangeRef.current,
|
|
270
|
+
lastEmittedRef.current,
|
|
271
|
+
)
|
|
272
|
+
) {
|
|
273
|
+
editor.commands.setContent(value, { emitUpdate: false })
|
|
274
|
+
lastEmittedRef.current = value
|
|
275
|
+
}
|
|
276
|
+
pendingFormatChangeRef.current = false
|
|
277
|
+
}, [editor, value, activeFormat, effectiveMode])
|
|
278
|
+
|
|
279
|
+
const handleFormatChange = React.useCallback(
|
|
280
|
+
(next: RichtextFormat) => {
|
|
281
|
+
if (next === activeFormat) return
|
|
282
|
+
// Set the guard BEFORE queueing the state update so the sync effect
|
|
283
|
+
// skips the intermediate render where activeFormat='next' but value
|
|
284
|
+
// still holds the previous format's string.
|
|
285
|
+
pendingFormatChangeRef.current = true
|
|
286
|
+
setActiveFormat(next)
|
|
287
|
+
if (editor) {
|
|
288
|
+
const emitted = readEditorContent(editor, next)
|
|
289
|
+
lastEmittedRef.current = emitted
|
|
290
|
+
onChange(emitted)
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
[activeFormat, editor, onChange],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const switchToMode = React.useCallback(
|
|
297
|
+
(next: RichtextMode) => {
|
|
298
|
+
// When entering a mode that shows the editor, refresh its content from
|
|
299
|
+
// value so any edits made in the source textarea (where the editor sync
|
|
300
|
+
// was suppressed) become visible. Also update lastEmittedRef so the
|
|
301
|
+
// upcoming sync effect treats the editor as authoritative.
|
|
302
|
+
if (next !== 'source' && editor) {
|
|
303
|
+
editor.commands.setContent(value, { emitUpdate: false })
|
|
304
|
+
lastEmittedRef.current = value
|
|
305
|
+
}
|
|
306
|
+
setMode(next)
|
|
307
|
+
},
|
|
308
|
+
[editor, value],
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
const toggleFullscreen = React.useCallback(() => {
|
|
312
|
+
setFullscreen((v) => !v)
|
|
313
|
+
}, [])
|
|
314
|
+
|
|
315
|
+
// Exit fullscreen on Escape, and lock body scroll while fullscreen.
|
|
316
|
+
React.useEffect(() => {
|
|
317
|
+
if (!fullscreen || typeof window === 'undefined') return
|
|
318
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
319
|
+
if (e.key === 'Escape') {
|
|
320
|
+
e.stopPropagation()
|
|
321
|
+
setFullscreen(false)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const prevOverflow = document.body.style.overflow
|
|
325
|
+
document.body.style.overflow = 'hidden'
|
|
326
|
+
window.addEventListener('keydown', onKey)
|
|
327
|
+
return () => {
|
|
328
|
+
window.removeEventListener('keydown', onKey)
|
|
329
|
+
document.body.style.overflow = prevOverflow
|
|
330
|
+
}
|
|
331
|
+
}, [fullscreen])
|
|
332
|
+
|
|
333
|
+
const promptLink = React.useCallback(() => {
|
|
334
|
+
if (!editor) return
|
|
335
|
+
const prev = (editor.getAttributes('link')?.href as string) ?? ''
|
|
336
|
+
const url = typeof window !== 'undefined' ? window.prompt(l.urlPrompt, prev) : null
|
|
337
|
+
if (url == null) return
|
|
338
|
+
if (url === '') {
|
|
339
|
+
editor.chain().focus().extendMarkRange('link').unsetMark('link').run()
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
// Tiptap 3's StarterKit doesn't ship Link by default; it's only available
|
|
343
|
+
// when the Link extension is included. Fall back to no-op if not present.
|
|
344
|
+
const chain = editor.chain().focus().extendMarkRange('link') as ReturnType<
|
|
345
|
+
typeof editor.chain
|
|
346
|
+
> & { setLink?: (a: { href: string }) => unknown }
|
|
347
|
+
if (typeof chain.setLink === 'function') {
|
|
348
|
+
chain.setLink({ href: url }).run()
|
|
349
|
+
}
|
|
350
|
+
}, [editor, l.urlPrompt])
|
|
351
|
+
|
|
352
|
+
if (!editor) {
|
|
353
|
+
return (
|
|
354
|
+
<div
|
|
355
|
+
className={cn(
|
|
356
|
+
'rounded-md border border-input bg-background text-sm text-muted-foreground',
|
|
357
|
+
className,
|
|
358
|
+
)}
|
|
359
|
+
>
|
|
360
|
+
<div className="p-3">{placeholder ?? ''}</div>
|
|
361
|
+
</div>
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<div
|
|
367
|
+
className={cn(
|
|
368
|
+
'rounded-md border border-input bg-background shadow-sm',
|
|
369
|
+
disabled && 'opacity-50',
|
|
370
|
+
fullscreen && 'fixed inset-0 z-50 flex flex-col rounded-none border-0 shadow-none',
|
|
371
|
+
className,
|
|
372
|
+
)}
|
|
373
|
+
>
|
|
374
|
+
{/* Toolbar */}
|
|
375
|
+
<div
|
|
376
|
+
className={cn(
|
|
377
|
+
'flex flex-wrap items-center gap-0.5 border-b border-border p-1',
|
|
378
|
+
fullscreen && 'shrink-0 bg-background',
|
|
379
|
+
)}
|
|
380
|
+
>
|
|
381
|
+
<ToolbarButton
|
|
382
|
+
title={l.bold}
|
|
383
|
+
active={editor.isActive('bold')}
|
|
384
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
385
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
386
|
+
>
|
|
387
|
+
<Bold className="size-4" />
|
|
388
|
+
</ToolbarButton>
|
|
389
|
+
<ToolbarButton
|
|
390
|
+
title={l.italic}
|
|
391
|
+
active={editor.isActive('italic')}
|
|
392
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
393
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
394
|
+
>
|
|
395
|
+
<Italic className="size-4" />
|
|
396
|
+
</ToolbarButton>
|
|
397
|
+
<ToolbarButton
|
|
398
|
+
title={l.strikethrough}
|
|
399
|
+
active={editor.isActive('strike')}
|
|
400
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
401
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
402
|
+
>
|
|
403
|
+
<Strikethrough className="size-4" />
|
|
404
|
+
</ToolbarButton>
|
|
405
|
+
<ToolbarButton
|
|
406
|
+
title={l.inlineCode}
|
|
407
|
+
active={editor.isActive('code')}
|
|
408
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
409
|
+
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
410
|
+
>
|
|
411
|
+
<Code className="size-4" />
|
|
412
|
+
</ToolbarButton>
|
|
413
|
+
|
|
414
|
+
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
|
415
|
+
|
|
416
|
+
{([1, 2, 3] as const).map((level) => {
|
|
417
|
+
const Icon = HEADING_ICON[level]
|
|
418
|
+
return (
|
|
419
|
+
<ToolbarButton
|
|
420
|
+
key={level}
|
|
421
|
+
title={l.heading.replace('{level}', String(level))}
|
|
422
|
+
active={editor.isActive('heading', { level })}
|
|
423
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
424
|
+
onClick={() => editor.chain().focus().toggleHeading({ level }).run()}
|
|
425
|
+
>
|
|
426
|
+
<Icon className="size-4" />
|
|
427
|
+
</ToolbarButton>
|
|
428
|
+
)
|
|
429
|
+
})}
|
|
430
|
+
|
|
431
|
+
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
|
432
|
+
|
|
433
|
+
<ToolbarButton
|
|
434
|
+
title={l.bulletList}
|
|
435
|
+
active={editor.isActive('bulletList')}
|
|
436
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
437
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
438
|
+
>
|
|
439
|
+
<List className="size-4" />
|
|
440
|
+
</ToolbarButton>
|
|
441
|
+
<ToolbarButton
|
|
442
|
+
title={l.numberedList}
|
|
443
|
+
active={editor.isActive('orderedList')}
|
|
444
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
445
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
446
|
+
>
|
|
447
|
+
<ListOrdered className="size-4" />
|
|
448
|
+
</ToolbarButton>
|
|
449
|
+
<ToolbarButton
|
|
450
|
+
title={l.blockquote}
|
|
451
|
+
active={editor.isActive('blockquote')}
|
|
452
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
453
|
+
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
454
|
+
>
|
|
455
|
+
<Quote className="size-4" />
|
|
456
|
+
</ToolbarButton>
|
|
457
|
+
<ToolbarButton
|
|
458
|
+
title={l.horizontalRule}
|
|
459
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
460
|
+
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
461
|
+
>
|
|
462
|
+
<Minus className="size-4" />
|
|
463
|
+
</ToolbarButton>
|
|
464
|
+
<ToolbarButton
|
|
465
|
+
title={l.insertLink}
|
|
466
|
+
active={editor.isActive('link')}
|
|
467
|
+
disabled={effectiveMode === 'source' || disabled}
|
|
468
|
+
onClick={promptLink}
|
|
469
|
+
>
|
|
470
|
+
<LinkIcon className="size-4" />
|
|
471
|
+
</ToolbarButton>
|
|
472
|
+
|
|
473
|
+
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
|
474
|
+
|
|
475
|
+
<ToolbarButton
|
|
476
|
+
title={l.undo}
|
|
477
|
+
disabled={effectiveMode === 'source' || !editor.can().undo() || disabled}
|
|
478
|
+
onClick={() => editor.chain().focus().undo().run()}
|
|
479
|
+
>
|
|
480
|
+
<Undo2 className="size-4" />
|
|
481
|
+
</ToolbarButton>
|
|
482
|
+
<ToolbarButton
|
|
483
|
+
title={l.redo}
|
|
484
|
+
disabled={effectiveMode === 'source' || !editor.can().redo() || disabled}
|
|
485
|
+
onClick={() => editor.chain().focus().redo().run()}
|
|
486
|
+
>
|
|
487
|
+
<Redo2 className="size-4" />
|
|
488
|
+
</ToolbarButton>
|
|
489
|
+
|
|
490
|
+
<div className="ml-auto" />
|
|
491
|
+
|
|
492
|
+
{/* Format selector — picks the on-disk markup of the value. Switching
|
|
493
|
+
re-emits the current editor content in the new format, so it can
|
|
494
|
+
be used mid-editing without losing state. */}
|
|
495
|
+
<div className="flex h-8 items-center overflow-hidden rounded-md border border-input">
|
|
496
|
+
{(['html', 'markdown'] as const).map((f) => (
|
|
497
|
+
<button
|
|
498
|
+
key={f}
|
|
499
|
+
type="button"
|
|
500
|
+
aria-pressed={activeFormat === f}
|
|
501
|
+
disabled={disabled}
|
|
502
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
503
|
+
onClick={() => handleFormatChange(f)}
|
|
504
|
+
className={cn(
|
|
505
|
+
'h-full cursor-pointer px-2 text-xs font-medium uppercase transition-colors',
|
|
506
|
+
activeFormat === f
|
|
507
|
+
? 'bg-secondary text-secondary-foreground'
|
|
508
|
+
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
|
509
|
+
disabled && 'pointer-events-none opacity-50',
|
|
510
|
+
)}
|
|
511
|
+
>
|
|
512
|
+
{f === 'html' ? 'HTML' : 'MD'}
|
|
513
|
+
</button>
|
|
514
|
+
))}
|
|
515
|
+
</div>
|
|
516
|
+
|
|
517
|
+
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
|
518
|
+
|
|
519
|
+
{/* View-mode selector — three modes: source / split / wysiwyg.
|
|
520
|
+
Split is only enabled in fullscreen since the side-by-side layout
|
|
521
|
+
requires the full viewport width to be usable. */}
|
|
522
|
+
<ToolbarButton
|
|
523
|
+
title={l.source.replace('{format}', activeFormat)}
|
|
524
|
+
active={effectiveMode === 'source'}
|
|
525
|
+
disabled={disabled}
|
|
526
|
+
onClick={() => switchToMode('source')}
|
|
527
|
+
>
|
|
528
|
+
<Code2 className="size-4" />
|
|
529
|
+
</ToolbarButton>
|
|
530
|
+
{/* Split button is hidden outside fullscreen — the side-by-side layout
|
|
531
|
+
requires full viewport width to be usable, so a disabled button
|
|
532
|
+
would just waste toolbar space. */}
|
|
533
|
+
{isSplitAvailable(fullscreen) && (
|
|
534
|
+
<ToolbarButton
|
|
535
|
+
title={l.splitView}
|
|
536
|
+
active={effectiveMode === 'split'}
|
|
537
|
+
disabled={disabled}
|
|
538
|
+
onClick={() => switchToMode('split')}
|
|
539
|
+
>
|
|
540
|
+
<Columns2 className="size-4" />
|
|
541
|
+
</ToolbarButton>
|
|
542
|
+
)}
|
|
543
|
+
<ToolbarButton
|
|
544
|
+
title={l.visualEditor}
|
|
545
|
+
active={effectiveMode === 'wysiwyg'}
|
|
546
|
+
disabled={disabled}
|
|
547
|
+
onClick={() => switchToMode('wysiwyg')}
|
|
548
|
+
>
|
|
549
|
+
<Eye className="size-4" />
|
|
550
|
+
</ToolbarButton>
|
|
551
|
+
|
|
552
|
+
<ToolbarButton
|
|
553
|
+
title={fullscreen ? l.exitFullscreen : l.fullscreen}
|
|
554
|
+
active={fullscreen}
|
|
555
|
+
onClick={toggleFullscreen}
|
|
556
|
+
>
|
|
557
|
+
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
|
558
|
+
</ToolbarButton>
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
{/* Body — three layouts driven by effectiveMode.
|
|
562
|
+
- source: textarea only
|
|
563
|
+
- wysiwyg: editor only
|
|
564
|
+
- split: textarea (left) + editor (right), both bound to the same
|
|
565
|
+
`value` so each pane stays in sync via onChange + the sync effect. */}
|
|
566
|
+
{effectiveMode === 'source' ? (
|
|
567
|
+
<Textarea
|
|
568
|
+
value={value}
|
|
569
|
+
onChange={(e) => onChange(e.target.value)}
|
|
570
|
+
onBlur={() => onBlur?.()}
|
|
571
|
+
placeholder={placeholder}
|
|
572
|
+
disabled={disabled}
|
|
573
|
+
spellCheck={false}
|
|
574
|
+
className={cn(
|
|
575
|
+
'w-full rounded-none border-0 font-mono text-xs shadow-none focus-visible:ring-0',
|
|
576
|
+
fullscreen ? 'min-h-0 flex-1 resize-none' : 'min-h-[160px] resize-y',
|
|
577
|
+
)}
|
|
578
|
+
/>
|
|
579
|
+
) : effectiveMode === 'split' ? (
|
|
580
|
+
<div
|
|
581
|
+
className={cn(
|
|
582
|
+
'flex w-full',
|
|
583
|
+
fullscreen ? 'min-h-0 flex-1' : 'min-h-[160px]',
|
|
584
|
+
)}
|
|
585
|
+
>
|
|
586
|
+
<Textarea
|
|
587
|
+
value={value}
|
|
588
|
+
onChange={(e) => onChange(e.target.value)}
|
|
589
|
+
onBlur={() => onBlur?.()}
|
|
590
|
+
placeholder={placeholder}
|
|
591
|
+
disabled={disabled}
|
|
592
|
+
spellCheck={false}
|
|
593
|
+
className={cn(
|
|
594
|
+
'w-1/2 rounded-none border-0 font-mono text-xs shadow-none focus-visible:ring-0',
|
|
595
|
+
fullscreen ? 'min-h-0 resize-none' : 'min-h-[160px] resize-none',
|
|
596
|
+
)}
|
|
597
|
+
/>
|
|
598
|
+
{/* Central divider — explicit fixed-width vertical bar so it's
|
|
599
|
+
clearly visible against both panes' backgrounds. */}
|
|
600
|
+
<div
|
|
601
|
+
aria-hidden
|
|
602
|
+
className="w-px shrink-0 self-stretch bg-border"
|
|
603
|
+
/>
|
|
604
|
+
<EditorContent
|
|
605
|
+
editor={editor}
|
|
606
|
+
className={cn(
|
|
607
|
+
'w-1/2 overflow-auto',
|
|
608
|
+
disabled && 'pointer-events-none',
|
|
609
|
+
fullscreen && 'min-h-0 [&_.ProseMirror]:min-h-full',
|
|
610
|
+
)}
|
|
611
|
+
/>
|
|
612
|
+
</div>
|
|
613
|
+
) : (
|
|
614
|
+
<EditorContent
|
|
615
|
+
editor={editor}
|
|
616
|
+
className={cn(
|
|
617
|
+
disabled && 'pointer-events-none',
|
|
618
|
+
fullscreen && 'min-h-0 flex-1 overflow-auto [&_.ProseMirror]:min-h-full',
|
|
619
|
+
)}
|
|
620
|
+
/>
|
|
621
|
+
)}
|
|
622
|
+
</div>
|
|
623
|
+
)
|
|
624
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Pure helpers for the RichtextEditor's view-mode logic.
|
|
2
|
+
//
|
|
3
|
+
// Three modes are supported:
|
|
4
|
+
// - 'wysiwyg' — only the Tiptap editor is visible
|
|
5
|
+
// - 'source' — only a Textarea (raw HTML or Markdown) is visible
|
|
6
|
+
// - 'split' — both side-by-side (only meaningful in fullscreen)
|
|
7
|
+
|
|
8
|
+
export type RichtextMode = 'wysiwyg' | 'source' | 'split'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether the external `value`→editor sync effect should run for the given mode.
|
|
12
|
+
*
|
|
13
|
+
* In 'source' the textarea owns the content; the editor is hidden and we skip
|
|
14
|
+
* setContent to avoid wasted work. In 'wysiwyg' and 'split' the editor is
|
|
15
|
+
* visible and must reflect any external change to `value`.
|
|
16
|
+
*/
|
|
17
|
+
export function shouldSyncEditor(mode: RichtextMode): boolean {
|
|
18
|
+
return mode !== 'source'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether the user can pick the split mode in the current fullscreen state.
|
|
23
|
+
* Split is only useful at full viewport width — outside fullscreen the panes
|
|
24
|
+
* would be too narrow to be usable.
|
|
25
|
+
*/
|
|
26
|
+
export function isSplitAvailable(fullscreen: boolean): boolean {
|
|
27
|
+
return fullscreen
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves the *effective* mode to render. When the user has selected
|
|
32
|
+
* split but exits fullscreen, we transparently fall back to wysiwyg so the
|
|
33
|
+
* layout stays usable. The selected mode is preserved by the caller so that
|
|
34
|
+
* re-entering fullscreen restores the split view.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveMode(mode: RichtextMode, fullscreen: boolean): RichtextMode {
|
|
37
|
+
if (mode === 'split' && !fullscreen) return 'wysiwyg'
|
|
38
|
+
return mode
|
|
39
|
+
}
|