@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.
Files changed (268) hide show
  1. package/dist/components/accordion.d.ts +7 -0
  2. package/dist/components/accordion.d.ts.map +1 -0
  3. package/dist/components/accordion.jsx +19 -0
  4. package/dist/components/accordion.jsx.map +1 -0
  5. package/dist/components/alert-dialog.d.ts +22 -0
  6. package/dist/components/alert-dialog.d.ts.map +1 -0
  7. package/dist/components/alert-dialog.jsx +27 -0
  8. package/dist/components/alert-dialog.jsx.map +1 -0
  9. package/dist/components/audit-timeline.d.ts +24 -0
  10. package/dist/components/audit-timeline.d.ts.map +1 -0
  11. package/dist/components/audit-timeline.jsx +60 -0
  12. package/dist/components/audit-timeline.jsx.map +1 -0
  13. package/dist/components/avatar.d.ts +6 -0
  14. package/dist/components/avatar.d.ts.map +1 -0
  15. package/dist/components/avatar.jsx +10 -0
  16. package/dist/components/avatar.jsx.map +1 -0
  17. package/dist/components/badge.d.ts +10 -0
  18. package/dist/components/badge.d.ts.map +1 -0
  19. package/dist/components/badge.jsx +19 -0
  20. package/dist/components/badge.jsx.map +1 -0
  21. package/dist/components/breadcrumb.d.ts +17 -0
  22. package/dist/components/breadcrumb.d.ts.map +1 -0
  23. package/dist/components/breadcrumb.jsx +27 -0
  24. package/dist/components/breadcrumb.jsx.map +1 -0
  25. package/dist/components/button.d.ts +12 -0
  26. package/dist/components/button.d.ts.map +1 -0
  27. package/dist/components/button.jsx +37 -0
  28. package/dist/components/button.jsx.map +1 -0
  29. package/dist/components/calendar.d.ts +9 -0
  30. package/dist/components/calendar.d.ts.map +1 -0
  31. package/dist/components/calendar.jsx +102 -0
  32. package/dist/components/calendar.jsx.map +1 -0
  33. package/dist/components/card.d.ts +8 -0
  34. package/dist/components/card.d.ts.map +1 -0
  35. package/dist/components/card.jsx +18 -0
  36. package/dist/components/card.jsx.map +1 -0
  37. package/dist/components/chart.d.ts +97 -0
  38. package/dist/components/chart.d.ts.map +1 -0
  39. package/dist/components/chart.jsx +233 -0
  40. package/dist/components/chart.jsx.map +1 -0
  41. package/dist/components/checkbox.d.ts +4 -0
  42. package/dist/components/checkbox.d.ts.map +1 -0
  43. package/dist/components/checkbox.jsx +11 -0
  44. package/dist/components/checkbox.jsx.map +1 -0
  45. package/dist/components/combobox.d.ts +46 -0
  46. package/dist/components/combobox.d.ts.map +1 -0
  47. package/dist/components/combobox.jsx +145 -0
  48. package/dist/components/combobox.jsx.map +1 -0
  49. package/dist/components/command.d.ts +80 -0
  50. package/dist/components/command.d.ts.map +1 -0
  51. package/dist/components/command.jsx +32 -0
  52. package/dist/components/command.jsx.map +1 -0
  53. package/dist/components/date-picker.d.ts +24 -0
  54. package/dist/components/date-picker.d.ts.map +1 -0
  55. package/dist/components/date-picker.jsx +149 -0
  56. package/dist/components/date-picker.jsx.map +1 -0
  57. package/dist/components/date-range-input.d.ts +22 -0
  58. package/dist/components/date-range-input.d.ts.map +1 -0
  59. package/dist/components/date-range-input.jsx +202 -0
  60. package/dist/components/date-range-input.jsx.map +1 -0
  61. package/dist/components/dialog.d.ts +19 -0
  62. package/dist/components/dialog.d.ts.map +1 -0
  63. package/dist/components/dialog.jsx +30 -0
  64. package/dist/components/dialog.jsx.map +1 -0
  65. package/dist/components/diff-view.d.ts +24 -0
  66. package/dist/components/diff-view.d.ts.map +1 -0
  67. package/dist/components/diff-view.jsx +69 -0
  68. package/dist/components/diff-view.jsx.map +1 -0
  69. package/dist/components/dropdown-menu.d.ts +27 -0
  70. package/dist/components/dropdown-menu.d.ts.map +1 -0
  71. package/dist/components/dropdown-menu.jsx +48 -0
  72. package/dist/components/dropdown-menu.jsx.map +1 -0
  73. package/dist/components/empty.d.ts +15 -0
  74. package/dist/components/empty.d.ts.map +1 -0
  75. package/dist/components/empty.jsx +27 -0
  76. package/dist/components/empty.jsx.map +1 -0
  77. package/dist/components/field.d.ts +23 -0
  78. package/dist/components/field.d.ts.map +1 -0
  79. package/dist/components/field.jsx +60 -0
  80. package/dist/components/field.jsx.map +1 -0
  81. package/dist/components/file-input.d.ts +50 -0
  82. package/dist/components/file-input.d.ts.map +1 -0
  83. package/dist/components/file-input.jsx +104 -0
  84. package/dist/components/file-input.jsx.map +1 -0
  85. package/dist/components/form.d.ts +20 -0
  86. package/dist/components/form.d.ts.map +1 -0
  87. package/dist/components/form.jsx +66 -0
  88. package/dist/components/form.jsx.map +1 -0
  89. package/dist/components/info-tooltip.d.ts +11 -0
  90. package/dist/components/info-tooltip.d.ts.map +1 -0
  91. package/dist/components/info-tooltip.jsx +17 -0
  92. package/dist/components/info-tooltip.jsx.map +1 -0
  93. package/dist/components/input.d.ts +13 -0
  94. package/dist/components/input.d.ts.map +1 -0
  95. package/dist/components/input.jsx +19 -0
  96. package/dist/components/input.jsx.map +1 -0
  97. package/dist/components/json-editor.d.ts +23 -0
  98. package/dist/components/json-editor.d.ts.map +1 -0
  99. package/dist/components/json-editor.jsx +143 -0
  100. package/dist/components/json-editor.jsx.map +1 -0
  101. package/dist/components/kbd.d.ts +15 -0
  102. package/dist/components/kbd.d.ts.map +1 -0
  103. package/dist/components/kbd.jsx +23 -0
  104. package/dist/components/kbd.jsx.map +1 -0
  105. package/dist/components/key-value-editor.d.ts +92 -0
  106. package/dist/components/key-value-editor.d.ts.map +1 -0
  107. package/dist/components/key-value-editor.jsx +187 -0
  108. package/dist/components/key-value-editor.jsx.map +1 -0
  109. package/dist/components/keyboard-shortcuts-help.d.ts +17 -0
  110. package/dist/components/keyboard-shortcuts-help.d.ts.map +1 -0
  111. package/dist/components/keyboard-shortcuts-help.jsx +97 -0
  112. package/dist/components/keyboard-shortcuts-help.jsx.map +1 -0
  113. package/dist/components/label.d.ts +5 -0
  114. package/dist/components/label.d.ts.map +1 -0
  115. package/dist/components/label.jsx +8 -0
  116. package/dist/components/label.jsx.map +1 -0
  117. package/dist/components/media-preview.d.ts +30 -0
  118. package/dist/components/media-preview.d.ts.map +1 -0
  119. package/dist/components/media-preview.jsx +189 -0
  120. package/dist/components/media-preview.jsx.map +1 -0
  121. package/dist/components/multi-file-input.d.ts +76 -0
  122. package/dist/components/multi-file-input.d.ts.map +1 -0
  123. package/dist/components/multi-file-input.jsx +131 -0
  124. package/dist/components/multi-file-input.jsx.map +1 -0
  125. package/dist/components/password-input.d.ts +10 -0
  126. package/dist/components/password-input.d.ts.map +1 -0
  127. package/dist/components/password-input.jsx +18 -0
  128. package/dist/components/password-input.jsx.map +1 -0
  129. package/dist/components/popover.d.ts +7 -0
  130. package/dist/components/popover.d.ts.map +1 -0
  131. package/dist/components/popover.jsx +11 -0
  132. package/dist/components/popover.jsx.map +1 -0
  133. package/dist/components/revision-timeline.d.ts +30 -0
  134. package/dist/components/revision-timeline.d.ts.map +1 -0
  135. package/dist/components/revision-timeline.jsx +42 -0
  136. package/dist/components/revision-timeline.jsx.map +1 -0
  137. package/dist/components/richtext-editor.d.ts +43 -0
  138. package/dist/components/richtext-editor.d.ts.map +1 -0
  139. package/dist/components/richtext-editor.jsx +319 -0
  140. package/dist/components/richtext-editor.jsx.map +1 -0
  141. package/dist/components/richtext-mode.d.ts +23 -0
  142. package/dist/components/richtext-mode.d.ts.map +1 -0
  143. package/dist/components/richtext-mode.js +36 -0
  144. package/dist/components/richtext-mode.js.map +1 -0
  145. package/dist/components/richtext-render.d.ts +8 -0
  146. package/dist/components/richtext-render.d.ts.map +1 -0
  147. package/dist/components/richtext-render.jsx +33 -0
  148. package/dist/components/richtext-render.jsx.map +1 -0
  149. package/dist/components/richtext-sync.d.ts +37 -0
  150. package/dist/components/richtext-sync.d.ts.map +1 -0
  151. package/dist/components/richtext-sync.js +46 -0
  152. package/dist/components/richtext-sync.js.map +1 -0
  153. package/dist/components/scroll-area.d.ts +5 -0
  154. package/dist/components/scroll-area.d.ts.map +1 -0
  155. package/dist/components/scroll-area.jsx +16 -0
  156. package/dist/components/scroll-area.jsx.map +1 -0
  157. package/dist/components/select.d.ts +36 -0
  158. package/dist/components/select.d.ts.map +1 -0
  159. package/dist/components/select.jsx +87 -0
  160. package/dist/components/select.jsx.map +1 -0
  161. package/dist/components/separator.d.ts +4 -0
  162. package/dist/components/separator.d.ts.map +1 -0
  163. package/dist/components/separator.jsx +6 -0
  164. package/dist/components/separator.jsx.map +1 -0
  165. package/dist/components/sheet.d.ts +29 -0
  166. package/dist/components/sheet.d.ts.map +1 -0
  167. package/dist/components/sheet.jsx +44 -0
  168. package/dist/components/sheet.jsx.map +1 -0
  169. package/dist/components/sidebar.d.ts +70 -0
  170. package/dist/components/sidebar.d.ts.map +1 -0
  171. package/dist/components/sidebar.jsx +245 -0
  172. package/dist/components/sidebar.jsx.map +1 -0
  173. package/dist/components/skeleton.d.ts +3 -0
  174. package/dist/components/skeleton.d.ts.map +1 -0
  175. package/dist/components/skeleton.jsx +6 -0
  176. package/dist/components/skeleton.jsx.map +1 -0
  177. package/dist/components/sonner.d.ts +6 -0
  178. package/dist/components/sonner.d.ts.map +1 -0
  179. package/dist/components/sonner.jsx +29 -0
  180. package/dist/components/sonner.jsx.map +1 -0
  181. package/dist/components/switch.d.ts +4 -0
  182. package/dist/components/switch.d.ts.map +1 -0
  183. package/dist/components/switch.jsx +8 -0
  184. package/dist/components/switch.jsx.map +1 -0
  185. package/dist/components/table.d.ts +10 -0
  186. package/dist/components/table.d.ts.map +1 -0
  187. package/dist/components/table.jsx +21 -0
  188. package/dist/components/table.jsx.map +1 -0
  189. package/dist/components/tabs.d.ts +7 -0
  190. package/dist/components/tabs.d.ts.map +1 -0
  191. package/dist/components/tabs.jsx +14 -0
  192. package/dist/components/tabs.jsx.map +1 -0
  193. package/dist/components/textarea.d.ts +4 -0
  194. package/dist/components/textarea.d.ts.map +1 -0
  195. package/dist/components/textarea.jsx +5 -0
  196. package/dist/components/textarea.jsx.map +1 -0
  197. package/dist/components/tooltip.d.ts +7 -0
  198. package/dist/components/tooltip.d.ts.map +1 -0
  199. package/dist/components/tooltip.jsx +11 -0
  200. package/dist/components/tooltip.jsx.map +1 -0
  201. package/dist/index.d.ts +52 -0
  202. package/dist/index.d.ts.map +1 -0
  203. package/dist/index.js +72 -0
  204. package/dist/index.js.map +1 -0
  205. package/dist/lib/theme.d.ts +11 -0
  206. package/dist/lib/theme.d.ts.map +1 -0
  207. package/dist/lib/theme.js +44 -0
  208. package/dist/lib/theme.js.map +1 -0
  209. package/dist/lib/utils.d.ts +3 -0
  210. package/dist/lib/utils.d.ts.map +1 -0
  211. package/dist/lib/utils.js +6 -0
  212. package/dist/lib/utils.js.map +1 -0
  213. package/dist/styles.css +242 -0
  214. package/package.json +85 -0
  215. package/src/components/accordion.tsx +48 -0
  216. package/src/components/alert-dialog.tsx +113 -0
  217. package/src/components/audit-timeline.tsx +102 -0
  218. package/src/components/avatar.tsx +42 -0
  219. package/src/components/badge.tsx +34 -0
  220. package/src/components/breadcrumb.tsx +99 -0
  221. package/src/components/button.tsx +58 -0
  222. package/src/components/calendar.tsx +176 -0
  223. package/src/components/card.tsx +60 -0
  224. package/src/components/chart.tsx +558 -0
  225. package/src/components/checkbox.tsx +23 -0
  226. package/src/components/combobox.tsx +264 -0
  227. package/src/components/command.tsx +120 -0
  228. package/src/components/date-picker.tsx +221 -0
  229. package/src/components/date-range-input.tsx +295 -0
  230. package/src/components/dialog.tsx +94 -0
  231. package/src/components/diff-view.tsx +182 -0
  232. package/src/components/dropdown-menu.tsx +165 -0
  233. package/src/components/empty.tsx +100 -0
  234. package/src/components/field.tsx +168 -0
  235. package/src/components/file-input.tsx +233 -0
  236. package/src/components/form.tsx +152 -0
  237. package/src/components/info-tooltip.tsx +40 -0
  238. package/src/components/input.tsx +55 -0
  239. package/src/components/json-editor.tsx +210 -0
  240. package/src/components/kbd.tsx +35 -0
  241. package/src/components/key-value-editor.tsx +423 -0
  242. package/src/components/keyboard-shortcuts-help.tsx +136 -0
  243. package/src/components/label.tsx +16 -0
  244. package/src/components/media-preview.tsx +278 -0
  245. package/src/components/multi-file-input.tsx +315 -0
  246. package/src/components/password-input.tsx +50 -0
  247. package/src/components/popover.tsx +26 -0
  248. package/src/components/revision-timeline.tsx +93 -0
  249. package/src/components/richtext-editor.tsx +624 -0
  250. package/src/components/richtext-mode.ts +39 -0
  251. package/src/components/richtext-render.tsx +51 -0
  252. package/src/components/richtext-sync.ts +57 -0
  253. package/src/components/scroll-area.tsx +41 -0
  254. package/src/components/select.tsx +200 -0
  255. package/src/components/separator.tsx +21 -0
  256. package/src/components/sheet.tsx +109 -0
  257. package/src/components/sidebar.tsx +660 -0
  258. package/src/components/skeleton.tsx +9 -0
  259. package/src/components/sonner.tsx +45 -0
  260. package/src/components/switch.tsx +24 -0
  261. package/src/components/table.tsx +93 -0
  262. package/src/components/tabs.tsx +57 -0
  263. package/src/components/textarea.tsx +18 -0
  264. package/src/components/tooltip.tsx +25 -0
  265. package/src/index.ts +342 -0
  266. package/src/lib/theme.ts +45 -0
  267. package/src/lib/utils.ts +6 -0
  268. 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
+ }