@marktiderman/genesis-ui 2.6.0 → 2.8.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 (70) hide show
  1. package/EXTENDING.md +40 -1
  2. package/SKILL.md +145 -9
  3. package/dist/{AppShell-CX0M-dfc.d.ts → AppShell-LXQykEqe.d.ts} +47 -5
  4. package/dist/{chunk-M62CHO3N.js → chunk-4C4ESW6O.js} +9 -4
  5. package/dist/chunk-4C4ESW6O.js.map +1 -0
  6. package/dist/{chunk-IXGAEECT.js → chunk-DWCAC2E7.js} +6 -3
  7. package/dist/chunk-DWCAC2E7.js.map +1 -0
  8. package/dist/chunk-FUVWKTPL.js +19 -0
  9. package/dist/chunk-FUVWKTPL.js.map +1 -0
  10. package/dist/{chunk-PLIYF6KI.js → chunk-MPI33I4K.js} +2 -2
  11. package/dist/{chunk-PLIYF6KI.js.map → chunk-MPI33I4K.js.map} +1 -1
  12. package/dist/{chunk-JA3ZKULS.js → chunk-OITWCIG2.js} +3 -3
  13. package/dist/chunk-OITWCIG2.js.map +1 -0
  14. package/dist/chunk-PKFDGGZV.js +65 -0
  15. package/dist/chunk-PKFDGGZV.js.map +1 -0
  16. package/dist/chunk-S7NXD37O.js +321 -0
  17. package/dist/chunk-S7NXD37O.js.map +1 -0
  18. package/dist/{chunk-DWM7ELFI.js → chunk-UYI3GLFJ.js} +163 -20
  19. package/dist/chunk-UYI3GLFJ.js.map +1 -0
  20. package/dist/{chunk-CDYZRZ6U.js → chunk-WGMEVX3Y.js} +2 -2
  21. package/dist/chunk-WGMEVX3Y.js.map +1 -0
  22. package/dist/chunk-ZDHWNQTR.js +552 -0
  23. package/dist/chunk-ZDHWNQTR.js.map +1 -0
  24. package/dist/components/data/index.d.ts +239 -15
  25. package/dist/components/data/index.js +524 -109
  26. package/dist/components/data/index.js.map +1 -1
  27. package/dist/components/layout/form-page.d.ts +1 -1
  28. package/dist/components/layout/form-page.js +1 -1
  29. package/dist/components/layout/index.d.ts +2 -2
  30. package/dist/components/layout/index.js +9 -9
  31. package/dist/components/patterns/index.d.ts +1 -0
  32. package/dist/components/patterns/index.js +6 -4
  33. package/dist/components/patterns/sortable-list.d.ts +78 -0
  34. package/dist/components/patterns/sortable-list.js +5 -0
  35. package/dist/components/patterns/sortable-list.js.map +1 -0
  36. package/dist/components/rich-text/rich-text-editor.d.ts +194 -0
  37. package/dist/components/rich-text/rich-text-editor.js +833 -0
  38. package/dist/components/rich-text/rich-text-editor.js.map +1 -0
  39. package/dist/components/rich-text/rich-text.d.ts +51 -0
  40. package/dist/components/rich-text/rich-text.js +4 -0
  41. package/dist/components/rich-text/rich-text.js.map +1 -0
  42. package/dist/components/ui/alert-dialog.d.ts +12 -1
  43. package/dist/components/ui/alert-dialog.js +1 -1
  44. package/dist/hooks/index.d.ts +3 -3
  45. package/dist/hooks/index.js +4 -4
  46. package/dist/hooks/use-data-filters.d.ts +5 -3
  47. package/dist/hooks/use-data-filters.js +2 -2
  48. package/dist/hooks/use-resource-page.d.ts +2 -1
  49. package/dist/hooks/use-resource-page.js +3 -2
  50. package/dist/hooks/use-view-preference.d.ts +17 -2
  51. package/dist/hooks/use-view-preference.js +2 -1
  52. package/dist/index.d.ts +2 -1
  53. package/dist/index.js +30 -29
  54. package/dist/{navigation-DBwcZ6vb.d.ts → navigation-Bha0R3Fu.d.ts} +6 -3
  55. package/dist/rich-text-doc-C54fME2C.d.ts +180 -0
  56. package/dist/router/react-router.d.ts +47 -0
  57. package/dist/router/react-router.js +53 -0
  58. package/dist/router/react-router.js.map +1 -0
  59. package/dist/router/tanstack-router.d.ts +47 -0
  60. package/dist/router/tanstack-router.js +105 -0
  61. package/dist/router/tanstack-router.js.map +1 -0
  62. package/dist/{use-resource-page-Dd2VUv30.d.ts → use-resource-page-D4byuwAs.d.ts} +20 -3
  63. package/package.json +62 -7
  64. package/dist/chunk-CDYZRZ6U.js.map +0 -1
  65. package/dist/chunk-DWM7ELFI.js.map +0 -1
  66. package/dist/chunk-FTTNGGLA.js +0 -20
  67. package/dist/chunk-FTTNGGLA.js.map +0 -1
  68. package/dist/chunk-IXGAEECT.js.map +0 -1
  69. package/dist/chunk-JA3ZKULS.js.map +0 -1
  70. package/dist/chunk-M62CHO3N.js.map +0 -1
@@ -0,0 +1,833 @@
1
+ "use client";
2
+ import { parseVideoUrl, sanitizeVideoEmbedAttrs, videoEmbedTitle, VIDEO_IFRAME_ATTRS, videoEmbedSrc, isRichTextDoc, isSafeUrl, sanitizeRichTextDoc, richTextClassName, richTextToHtml, RichText, normalizeLinkUrl, isRichTextEmpty, richTextToPlainText } from '../../chunk-ZDHWNQTR.js';
3
+ import { ToolbarButton, Toolbar, ToolbarSeparator, ToolbarToggleGroup, ToolbarToggleItem } from '../../chunk-YYEGAFUE.js';
4
+ import '../../chunk-C46ITXE4.js';
5
+ import { Tooltip } from '../../chunk-G5YKBPBF.js';
6
+ import { Kbd } from '../../chunk-FFSDFQQN.js';
7
+ import { Input } from '../../chunk-QSXVL5UB.js';
8
+ import { Popover, PopoverTrigger, PopoverContent } from '../../chunk-22XB3Q6H.js';
9
+ import { Button } from '../../chunk-NWSCYLPT.js';
10
+ import { Label } from '../../chunk-F47U5KNW.js';
11
+ import { cn } from '../../chunk-NZ5WBA4R.js';
12
+ import * as React from 'react';
13
+ import { Node, Extension } from '@tiptap/core';
14
+ import { useEditor, useEditorState, EditorContent } from '@tiptap/react';
15
+ import StarterKit from '@tiptap/starter-kit';
16
+ import { Placeholder, CharacterCount } from '@tiptap/extensions';
17
+ import { Bold, Italic, Underline, List, ListOrdered, Quote, Undo2, Redo2, Link, Video, Heading2, Heading3, Heading4 } from 'lucide-react';
18
+ import { jsxs, jsx } from 'react/jsx-runtime';
19
+
20
+ var noRender = { renderHTML: () => ({}) };
21
+ var VideoEmbed = Node.create({
22
+ name: "videoEmbed",
23
+ group: "block",
24
+ atom: true,
25
+ draggable: true,
26
+ selectable: true,
27
+ addAttributes() {
28
+ return {
29
+ provider: { default: null, ...noRender },
30
+ id: { default: null, ...noRender },
31
+ hash: { default: null, ...noRender },
32
+ title: { default: null, ...noRender }
33
+ };
34
+ },
35
+ parseHTML() {
36
+ return [
37
+ {
38
+ tag: "div[data-video-embed]",
39
+ priority: 60,
40
+ getAttrs: (el) => {
41
+ const attrs = sanitizeVideoEmbedAttrs({
42
+ provider: el.getAttribute("data-provider"),
43
+ id: el.getAttribute("data-id"),
44
+ hash: el.getAttribute("data-hash") ?? void 0,
45
+ title: el.getAttribute("data-title") ?? void 0
46
+ });
47
+ return attrs ? { ...attrs } : false;
48
+ }
49
+ },
50
+ {
51
+ tag: "iframe[src]",
52
+ getAttrs: (el) => {
53
+ const parsed = parseVideoUrl(el.getAttribute("src") ?? "");
54
+ if (!parsed) return false;
55
+ const attrs = sanitizeVideoEmbedAttrs({ ...parsed, title: el.getAttribute("title") ?? void 0 });
56
+ return attrs ? { ...attrs } : false;
57
+ }
58
+ }
59
+ ];
60
+ },
61
+ renderHTML({ node }) {
62
+ const attrs = sanitizeVideoEmbedAttrs(node.attrs);
63
+ if (!attrs) return ["div", { "data-video-embed-invalid": "" }];
64
+ const title = videoEmbedTitle(attrs);
65
+ return [
66
+ "div",
67
+ {
68
+ "data-video-embed": "",
69
+ "data-provider": attrs.provider,
70
+ "data-id": attrs.id,
71
+ ...attrs.hash ? { "data-hash": attrs.hash } : {},
72
+ ...attrs.title ? { "data-title": attrs.title } : {}
73
+ },
74
+ [
75
+ "iframe",
76
+ {
77
+ src: videoEmbedSrc(attrs),
78
+ title,
79
+ allow: VIDEO_IFRAME_ATTRS.allow,
80
+ allowfullscreen: "true",
81
+ loading: VIDEO_IFRAME_ATTRS.loading,
82
+ referrerpolicy: VIDEO_IFRAME_ATTRS.referrerPolicy,
83
+ sandbox: VIDEO_IFRAME_ATTRS.sandbox,
84
+ // Inside the editor the frame is not a tab stop: the node is
85
+ // selected and deleted as a whole, and playback happens in the
86
+ // read-only view.
87
+ tabindex: "-1"
88
+ }
89
+ ]
90
+ ];
91
+ },
92
+ addCommands() {
93
+ return {
94
+ setVideoEmbed: ({ url, title }) => ({ commands, state }) => {
95
+ const parsed = parseVideoUrl(url);
96
+ if (!parsed) return false;
97
+ const attrs = sanitizeVideoEmbedAttrs({ ...parsed, title });
98
+ if (!attrs) return false;
99
+ const node = { type: this.name, attrs: { ...attrs } };
100
+ const { $from } = state.selection;
101
+ if ($from.depth === 0) return commands.insertContent(node);
102
+ const block = $from.node(1);
103
+ if (block.type.name === "paragraph" && block.content.size === 0) {
104
+ return commands.insertContentAt({ from: $from.before(1), to: $from.after(1) }, node);
105
+ }
106
+ return commands.insertContentAt($from.after(1), node);
107
+ }
108
+ };
109
+ }
110
+ });
111
+ var DEFAULT_LABELS = {
112
+ toolbar: "Formatting",
113
+ blockGroup: "Paragraph style",
114
+ markGroup: "Text style",
115
+ heading2: "Heading 2",
116
+ heading3: "Heading 3",
117
+ heading4: "Heading 4",
118
+ bold: "Bold",
119
+ italic: "Italic",
120
+ underline: "Underline",
121
+ bulletList: "Bulleted list",
122
+ orderedList: "Numbered list",
123
+ blockquote: "Quote",
124
+ link: "Link",
125
+ video: "Embed video",
126
+ undo: "Undo",
127
+ redo: "Redo",
128
+ linkUrl: "Link address",
129
+ linkApply: "Apply",
130
+ linkRemove: "Remove link",
131
+ linkInvalid: "Enter a web address (https://\u2026), an email address or a relative path.",
132
+ videoUrl: "YouTube, Vimeo or Loom link",
133
+ videoTitle: "Title (read by screen readers)",
134
+ videoInsert: "Insert",
135
+ videoInvalid: "Paste a YouTube, Vimeo or Loom video link.",
136
+ cancel: "Cancel",
137
+ characterCount: (count, max) => max ? `${count.toLocaleString()} / ${max.toLocaleString()} characters` : `${count.toLocaleString()} characters`
138
+ };
139
+ var MARKS = [
140
+ {
141
+ id: "bold",
142
+ icon: Bold,
143
+ keys: ["Mod", "B"],
144
+ isActive: (e) => e.isActive("bold"),
145
+ run: (e) => e.chain().focus().toggleBold().run()
146
+ },
147
+ {
148
+ id: "italic",
149
+ icon: Italic,
150
+ keys: ["Mod", "I"],
151
+ isActive: (e) => e.isActive("italic"),
152
+ run: (e) => e.chain().focus().toggleItalic().run()
153
+ },
154
+ {
155
+ id: "underline",
156
+ icon: Underline,
157
+ keys: ["Mod", "U"],
158
+ isActive: (e) => e.isActive("underline"),
159
+ run: (e) => e.chain().focus().toggleUnderline().run()
160
+ }
161
+ ];
162
+ var heading = (level, icon) => ({
163
+ id: `heading${level}`,
164
+ icon,
165
+ keys: ["Mod", "Alt", String(level)],
166
+ isActive: (e) => e.isActive("heading", { level }),
167
+ run: (e) => e.chain().focus().toggleHeading({ level }).run()
168
+ });
169
+ var BLOCKS = [
170
+ heading(2, Heading2),
171
+ heading(3, Heading3),
172
+ heading(4, Heading4),
173
+ {
174
+ id: "bulletList",
175
+ icon: List,
176
+ keys: ["Mod", "Shift", "8"],
177
+ isActive: (e) => e.isActive("bulletList"),
178
+ run: (e) => e.chain().focus().toggleBulletList().run()
179
+ },
180
+ {
181
+ id: "orderedList",
182
+ icon: ListOrdered,
183
+ keys: ["Mod", "Shift", "7"],
184
+ isActive: (e) => e.isActive("orderedList"),
185
+ run: (e) => e.chain().focus().toggleOrderedList().run()
186
+ },
187
+ {
188
+ id: "blockquote",
189
+ icon: Quote,
190
+ keys: ["Mod", "Shift", "B"],
191
+ isActive: (e) => e.isActive("blockquote"),
192
+ run: (e) => e.chain().focus().toggleBlockquote().run()
193
+ }
194
+ ];
195
+ var ALL_TOGGLES = [...MARKS, ...BLOCKS];
196
+ var PROSEMIRROR_BASE = cn(
197
+ "relative break-words whitespace-break-spaces [font-variant-ligatures:none] [font-feature-settings:'liga'_0]",
198
+ "[&_[contenteditable=false]]:whitespace-normal [&_[contenteditable=false]_[contenteditable=true]]:whitespace-pre-wrap [&_pre]:whitespace-pre-wrap",
199
+ "[&_img.ProseMirror-separator]:!inline [&_img.ProseMirror-separator]:!m-0 [&_img.ProseMirror-separator]:!h-0 [&_img.ProseMirror-separator]:!w-0 [&_img.ProseMirror-separator]:!border-0",
200
+ "[&_.ProseMirror-gapcursor]:pointer-events-none [&_.ProseMirror-gapcursor]:absolute [&_.ProseMirror-gapcursor]:m-0 [&_.ProseMirror-gapcursor]:hidden [&.ProseMirror-focused_.ProseMirror-gapcursor]:block",
201
+ "[&_.ProseMirror-gapcursor]:after:absolute [&_.ProseMirror-gapcursor]:after:-top-0.5 [&_.ProseMirror-gapcursor]:after:block [&_.ProseMirror-gapcursor]:after:w-5 [&_.ProseMirror-gapcursor]:after:border-t [&_.ProseMirror-gapcursor]:after:border-foreground [&_.ProseMirror-gapcursor]:after:content-['']",
202
+ "[&.ProseMirror-hideselection_*]:caret-transparent [&.ProseMirror-hideselection_*]:selection:bg-transparent"
203
+ );
204
+ var PRESSED_CLASS = "h-8 w-8 px-0 aria-pressed:bg-accent aria-pressed:text-accent-foreground";
205
+ function useIsApple() {
206
+ const [apple, setApple] = React.useState(false);
207
+ React.useEffect(() => {
208
+ const nav = navigator;
209
+ const platform = nav.userAgentData?.platform ?? nav.platform ?? "";
210
+ setApple(/mac|iphone|ipad|ipod/i.test(platform));
211
+ }, []);
212
+ return apple;
213
+ }
214
+ function keyLabel(key, apple) {
215
+ if (key === "Mod") return apple ? "\u2318" : "Ctrl";
216
+ if (key === "Alt") return apple ? "\u2325" : "Alt";
217
+ if (key === "Shift") return apple ? "\u21E7" : "Shift";
218
+ return key;
219
+ }
220
+ function ariaKeys(keys, apple) {
221
+ return keys.map((k) => k === "Mod" ? apple ? "Meta" : "Control" : k).join("+");
222
+ }
223
+ function ShortcutHint({ label, keys, apple }) {
224
+ return /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-2", children: [
225
+ label,
226
+ keys && /* @__PURE__ */ jsx("span", { className: "inline-flex items-center gap-0.5", children: keys.map((k) => /* @__PURE__ */ jsx(Kbd, { children: keyLabel(k, apple) }, k)) })
227
+ ] });
228
+ }
229
+ function initialContent(value, opts) {
230
+ if (typeof value === "string") return value;
231
+ return sanitizeRichTextDoc(value ?? null, opts);
232
+ }
233
+ function detailsFor(doc, characters, opts) {
234
+ let html;
235
+ let text;
236
+ return {
237
+ get html() {
238
+ return html ??= richTextToHtml(doc, opts);
239
+ },
240
+ get text() {
241
+ return text ??= richTextToPlainText(doc);
242
+ },
243
+ get isEmpty() {
244
+ return isRichTextEmpty(doc);
245
+ },
246
+ get characters() {
247
+ return characters();
248
+ }
249
+ };
250
+ }
251
+ var RichTextEditor = React.forwardRef(
252
+ function RichTextEditor2(props, ref) {
253
+ const {
254
+ value,
255
+ defaultValue,
256
+ onChange,
257
+ onFocus,
258
+ onBlur,
259
+ id: idProp,
260
+ name,
261
+ "aria-label": ariaLabel,
262
+ "aria-labelledby": ariaLabelledBy,
263
+ "aria-describedby": ariaDescribedBy,
264
+ invalid = false,
265
+ placeholder,
266
+ readOnly = false,
267
+ disabled = false,
268
+ autoFocus = false,
269
+ headingLevels = [2, 3],
270
+ embeds = false,
271
+ maxLength,
272
+ showCharacterCount,
273
+ toolbar,
274
+ labels: labelsProp,
275
+ className,
276
+ contentClassName,
277
+ testID
278
+ } = props;
279
+ const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelsProp }), [labelsProp]);
280
+ const generatedId = React.useId();
281
+ const editorId = idProp ?? `rte-${generatedId}`;
282
+ const countId = `${editorId}-count`;
283
+ const apple = useIsApple();
284
+ const [config] = React.useState(() => ({
285
+ opts: { headingLevels, embeds },
286
+ maxLength
287
+ }));
288
+ const { opts } = config;
289
+ const onChangeRef = React.useRef(onChange);
290
+ const onFocusRef = React.useRef(onFocus);
291
+ const onBlurRef = React.useRef(onBlur);
292
+ const placeholderRef = React.useRef(placeholder);
293
+ const stringControlledRef = React.useRef(typeof value === "string");
294
+ const openLinkRef = React.useRef(() => false);
295
+ React.useEffect(() => {
296
+ onChangeRef.current = onChange;
297
+ onFocusRef.current = onFocus;
298
+ onBlurRef.current = onBlur;
299
+ placeholderRef.current = placeholder;
300
+ stringControlledRef.current = typeof value === "string";
301
+ });
302
+ const lastEmitted = React.useRef({
303
+ doc: null,
304
+ html: null
305
+ });
306
+ const [initial] = React.useState(() => initialContent(value ?? defaultValue, opts));
307
+ const [formValue, setFormValue] = React.useState(
308
+ () => isRichTextDoc(initial) ? JSON.stringify(initial) : ""
309
+ );
310
+ const extensions = React.useMemo(
311
+ () => [
312
+ StarterKit.configure({
313
+ code: false,
314
+ codeBlock: false,
315
+ strike: false,
316
+ horizontalRule: false,
317
+ heading: { levels: [...opts.headingLevels] },
318
+ link: {
319
+ openOnClick: false,
320
+ autolink: true,
321
+ linkOnPaste: true,
322
+ defaultProtocol: "https",
323
+ // Same rule as the sanitizer, so a link the editor shows is a link
324
+ // that survives `onChange`.
325
+ isAllowedUri: (url, ctx) => ctx.defaultValidate(url) && isSafeUrl(url)
326
+ }
327
+ }),
328
+ Placeholder.configure({ placeholder: () => placeholderRef.current ?? "" }),
329
+ CharacterCount.configure({ limit: config.maxLength ?? null }),
330
+ ...opts.embeds ? [VideoEmbed] : [],
331
+ Extension.create({
332
+ name: "genesisLinkShortcut",
333
+ addKeyboardShortcuts: () => ({
334
+ // Only claims the shortcut when the link control is there to open.
335
+ "Mod-k": () => openLinkRef.current()
336
+ })
337
+ })
338
+ ],
339
+ // Mount-time only, by design (see `config` above).
340
+ // eslint-disable-next-line react-hooks/exhaustive-deps
341
+ []
342
+ );
343
+ const editor = useEditor({
344
+ extensions,
345
+ content: initial,
346
+ immediatelyRender: false,
347
+ // Tiptap's base styles are in PROSEMIRROR_BASE instead of an injected
348
+ // <style> tag, so the editor works under a strict style-src CSP.
349
+ injectCSS: false,
350
+ shouldRerenderOnTransaction: false,
351
+ editable: !readOnly && !disabled,
352
+ autofocus: autoFocus ? "end" : false,
353
+ onUpdate: ({ editor: e }) => {
354
+ const doc = sanitizeRichTextDoc(e.getJSON(), opts);
355
+ const details = detailsFor(doc, () => e.storage.characterCount.characters(), opts);
356
+ lastEmitted.current = { doc, html: null };
357
+ if (name) setFormValue(JSON.stringify(doc));
358
+ const cb = onChangeRef.current;
359
+ if (cb) {
360
+ cb(doc, details);
361
+ if (stringControlledRef.current) lastEmitted.current.html = details.html;
362
+ }
363
+ },
364
+ onFocus: () => onFocusRef.current?.(),
365
+ onBlur: () => onBlurRef.current?.()
366
+ });
367
+ const state = useEditorState({
368
+ editor,
369
+ // Reads the `editor` from this render rather than the snapshot's: the
370
+ // snapshot only refreshes on a transaction, so right after mount it
371
+ // still holds `null` and the toolbar (and count) would start blank.
372
+ selector: () => {
373
+ const e = editor;
374
+ if (!e) return null;
375
+ const active = {};
376
+ for (const def of ALL_TOGGLES) active[def.id] = def.isActive(e);
377
+ return {
378
+ active,
379
+ link: e.isActive("link"),
380
+ canUndo: e.can().undo(),
381
+ canRedo: e.can().redo(),
382
+ characters: e.storage.characterCount.characters()
383
+ };
384
+ }
385
+ });
386
+ React.useEffect(() => {
387
+ if (!editor) return;
388
+ if (typeof initial === "string") {
389
+ lastEmitted.current = { doc: null, html: initial };
390
+ setFormValue(JSON.stringify(sanitizeRichTextDoc(editor.getJSON(), opts)));
391
+ }
392
+ }, [editor, initial, opts]);
393
+ const [labelId, setLabelId] = React.useState(void 0);
394
+ React.useEffect(() => {
395
+ if (!editor || ariaLabel || ariaLabelledBy || typeof document === "undefined") return;
396
+ const label = document.querySelector(`label[for="${CSS.escape(editorId)}"]`);
397
+ if (!label) return;
398
+ if (!label.id) label.id = `${editorId}-label`;
399
+ setLabelId(label.id);
400
+ const onClick = (event) => {
401
+ event.preventDefault();
402
+ editor.commands.focus();
403
+ };
404
+ label.addEventListener("click", onClick);
405
+ return () => label.removeEventListener("click", onClick);
406
+ }, [editor, editorId, ariaLabel, ariaLabelledBy]);
407
+ React.useEffect(() => {
408
+ editor?.setEditable(!readOnly && !disabled, false);
409
+ }, [editor, readOnly, disabled]);
410
+ React.useEffect(() => {
411
+ if (!editor || value === void 0) return;
412
+ const last = lastEmitted.current;
413
+ if (typeof value === "string") {
414
+ if (value === last.html) return;
415
+ } else if (value === last.doc) {
416
+ return;
417
+ }
418
+ const next = initialContent(value, opts);
419
+ if (typeof next !== "string" && JSON.stringify(sanitizeRichTextDoc(editor.getJSON(), opts)) === JSON.stringify(next)) {
420
+ return;
421
+ }
422
+ editor.commands.setContent(next, { emitUpdate: false });
423
+ const doc = sanitizeRichTextDoc(editor.getJSON(), opts);
424
+ lastEmitted.current = { doc: typeof value === "string" ? null : value, html: typeof value === "string" ? value : null };
425
+ if (name) setFormValue(JSON.stringify(doc));
426
+ }, [editor, value, opts, name]);
427
+ const countVisible = showCharacterCount ?? config.maxLength !== void 0;
428
+ const describedBy = [ariaDescribedBy, countVisible ? countId : null].filter(Boolean).join(" ") || void 0;
429
+ const contentClasses = cn(
430
+ richTextClassName,
431
+ PROSEMIRROR_BASE,
432
+ "min-h-32 px-3 py-2 outline-none",
433
+ // Placeholder (Tiptap marks the empty first node with data-placeholder).
434
+ "[&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:text-muted-foreground [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]",
435
+ // Embeds are selected and deleted as a whole while editing.
436
+ "[&_[data-video-embed]_iframe]:pointer-events-none [&_.ProseMirror-selectednode]:ring-2 [&_.ProseMirror-selectednode]:ring-ring",
437
+ contentClassName
438
+ );
439
+ React.useEffect(() => {
440
+ if (!editor) return;
441
+ const attributes = {
442
+ id: editorId,
443
+ role: "textbox",
444
+ "aria-multiline": "true",
445
+ class: contentClasses
446
+ };
447
+ if (ariaLabel) attributes["aria-label"] = ariaLabel;
448
+ if (ariaLabelledBy ?? labelId) attributes["aria-labelledby"] = ariaLabelledBy ?? labelId;
449
+ if (describedBy) attributes["aria-describedby"] = describedBy;
450
+ if (invalid) attributes["aria-invalid"] = "true";
451
+ if (readOnly) attributes["aria-readonly"] = "true";
452
+ if (disabled) attributes["aria-disabled"] = "true";
453
+ if (placeholder) attributes["aria-placeholder"] = placeholder;
454
+ editor.setOptions({ editorProps: { ...editor.options.editorProps, attributes } });
455
+ }, [editor, editorId, contentClasses, ariaLabel, ariaLabelledBy, labelId, describedBy, invalid, readOnly, disabled, placeholder]);
456
+ React.useImperativeHandle(
457
+ ref,
458
+ () => ({
459
+ focus: () => editor?.commands.focus(),
460
+ clear: () => {
461
+ editor?.commands.clearContent(true);
462
+ },
463
+ getJSON: () => sanitizeRichTextDoc(editor?.getJSON() ?? initial, opts),
464
+ getHTML: () => richTextToHtml(editor?.getJSON() ?? initial, opts),
465
+ get editor() {
466
+ return editor;
467
+ }
468
+ }),
469
+ [editor, initial, opts]
470
+ );
471
+ const [linkOpen, setLinkOpen] = React.useState(false);
472
+ const [linkFromShortcut, setLinkFromShortcut] = React.useState(false);
473
+ const [videoOpen, setVideoOpen] = React.useState(false);
474
+ const interactive = !!editor && !readOnly && !disabled;
475
+ const items = React.useMemo(() => {
476
+ const levels = opts.headingLevels;
477
+ const defaults = [
478
+ "heading2",
479
+ "heading3",
480
+ "heading4",
481
+ "bold",
482
+ "italic",
483
+ "underline",
484
+ "bulletList",
485
+ "orderedList",
486
+ "blockquote",
487
+ "link",
488
+ "video",
489
+ "undo",
490
+ "redo"
491
+ ];
492
+ return new Set(
493
+ (toolbar || defaults).filter((item) => {
494
+ if (item.startsWith("heading")) return levels.includes(Number(item.slice(7)));
495
+ if (item === "video") return opts.embeds;
496
+ return true;
497
+ })
498
+ );
499
+ }, [toolbar, opts]);
500
+ const renderToggleGroup = (defs, groupLabel) => {
501
+ const shown = defs.filter((d) => items.has(d.id));
502
+ if (shown.length === 0) return null;
503
+ const activeIds = shown.filter((d) => state?.active[d.id]).map((d) => d.id);
504
+ return /* @__PURE__ */ jsx(
505
+ ToolbarToggleGroup,
506
+ {
507
+ type: "multiple",
508
+ "aria-label": groupLabel,
509
+ value: activeIds,
510
+ disabled: !interactive,
511
+ onValueChange: (next) => {
512
+ if (!editor) return;
513
+ for (const def of shown) {
514
+ if (next.includes(def.id) !== activeIds.includes(def.id)) def.run(editor);
515
+ }
516
+ },
517
+ children: shown.map((def) => {
518
+ const Icon = def.icon;
519
+ const label = labels[def.id];
520
+ return /* @__PURE__ */ jsx(Tooltip, { delayDuration: 300, content: /* @__PURE__ */ jsx(ShortcutHint, { label, keys: def.keys, apple }), children: /* @__PURE__ */ jsx(
521
+ ToolbarToggleItem,
522
+ {
523
+ value: def.id,
524
+ "aria-label": label,
525
+ "aria-keyshortcuts": ariaKeys(def.keys, apple),
526
+ className: PRESSED_CLASS,
527
+ children: /* @__PURE__ */ jsx(Icon, { className: "size-4", "aria-hidden": true })
528
+ }
529
+ ) }, def.id);
530
+ })
531
+ }
532
+ );
533
+ };
534
+ const groups = [];
535
+ const blockGroup = renderToggleGroup(BLOCKS, labels.blockGroup);
536
+ const markGroup = renderToggleGroup(MARKS, labels.markGroup);
537
+ if (blockGroup) groups.push(/* @__PURE__ */ jsx(React.Fragment, { children: blockGroup }, "blocks"));
538
+ if (markGroup) groups.push(/* @__PURE__ */ jsx(React.Fragment, { children: markGroup }, "marks"));
539
+ if (items.has("link") || items.has("video")) {
540
+ groups.push(
541
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
542
+ items.has("link") && /* @__PURE__ */ jsx(
543
+ LinkPopover,
544
+ {
545
+ editor,
546
+ open: linkOpen,
547
+ onOpenChange: setLinkOpen,
548
+ returnToEditor: linkFromShortcut,
549
+ onClosed: () => setLinkFromShortcut(false),
550
+ disabled: !interactive,
551
+ pressed: !!state?.link,
552
+ labels,
553
+ apple
554
+ }
555
+ ),
556
+ items.has("video") && /* @__PURE__ */ jsx(
557
+ VideoPopover,
558
+ {
559
+ editor,
560
+ open: videoOpen,
561
+ onOpenChange: setVideoOpen,
562
+ disabled: !interactive,
563
+ labels
564
+ }
565
+ )
566
+ ] }, "insert")
567
+ );
568
+ }
569
+ if (items.has("undo") || items.has("redo")) {
570
+ groups.push(
571
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
572
+ items.has("undo") && /* @__PURE__ */ jsx(Tooltip, { delayDuration: 300, content: /* @__PURE__ */ jsx(ShortcutHint, { label: labels.undo, keys: ["Mod", "Z"], apple }), children: /* @__PURE__ */ jsx(
573
+ ToolbarButton,
574
+ {
575
+ "aria-label": labels.undo,
576
+ "aria-keyshortcuts": ariaKeys(["Mod", "Z"], apple),
577
+ className: "h-8 w-8 px-0",
578
+ disabled: !interactive || !state?.canUndo,
579
+ onClick: () => editor?.chain().focus().undo().run(),
580
+ children: /* @__PURE__ */ jsx(Undo2, { className: "size-4", "aria-hidden": true })
581
+ }
582
+ ) }),
583
+ items.has("redo") && /* @__PURE__ */ jsx(
584
+ Tooltip,
585
+ {
586
+ delayDuration: 300,
587
+ content: /* @__PURE__ */ jsx(ShortcutHint, { label: labels.redo, keys: ["Mod", "Shift", "Z"], apple }),
588
+ children: /* @__PURE__ */ jsx(
589
+ ToolbarButton,
590
+ {
591
+ "aria-label": labels.redo,
592
+ "aria-keyshortcuts": ariaKeys(["Mod", "Shift", "Z"], apple),
593
+ className: "h-8 w-8 px-0",
594
+ disabled: !interactive || !state?.canRedo,
595
+ onClick: () => editor?.chain().focus().redo().run(),
596
+ children: /* @__PURE__ */ jsx(Redo2, { className: "size-4", "aria-hidden": true })
597
+ }
598
+ )
599
+ }
600
+ )
601
+ ] }, "history")
602
+ );
603
+ }
604
+ const showToolbar = toolbar !== false && !readOnly && groups.length > 0;
605
+ const linkAvailable = interactive && showToolbar && items.has("link");
606
+ React.useLayoutEffect(() => {
607
+ openLinkRef.current = () => {
608
+ if (!linkAvailable) return false;
609
+ setLinkFromShortcut(true);
610
+ setLinkOpen(true);
611
+ return true;
612
+ };
613
+ }, [linkAvailable]);
614
+ return /* @__PURE__ */ jsxs(
615
+ "div",
616
+ {
617
+ "data-testid": testID,
618
+ "data-disabled": disabled ? "" : void 0,
619
+ "data-invalid": invalid ? "" : void 0,
620
+ className: cn(
621
+ "w-full overflow-hidden rounded-md border border-input bg-background ring-offset-background",
622
+ "focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
623
+ invalid && "border-destructive",
624
+ disabled && "cursor-not-allowed opacity-50",
625
+ readOnly && "border-transparent",
626
+ className
627
+ ),
628
+ children: [
629
+ showToolbar && /* @__PURE__ */ jsx(
630
+ Toolbar,
631
+ {
632
+ "aria-label": labels.toolbar,
633
+ "aria-controls": editorId,
634
+ className: "flex-wrap rounded-none border-0 border-b border-border",
635
+ children: groups.flatMap(
636
+ (group, i) => i === 0 ? [group] : [/* @__PURE__ */ jsx(ToolbarSeparator, {}, `sep-${i}`), group]
637
+ )
638
+ }
639
+ ),
640
+ editor ? /* @__PURE__ */ jsx(EditorContent, { editor }) : (
641
+ // Server render and first client paint: show the content read-only
642
+ // so the page is not blank (or shifting) while the editor mounts.
643
+ /* @__PURE__ */ jsx(
644
+ RichText,
645
+ {
646
+ value: isRichTextDoc(initial) ? initial : null,
647
+ "aria-busy": "true",
648
+ className: cn("min-h-32 px-3 py-2", contentClassName)
649
+ }
650
+ )
651
+ ),
652
+ countVisible && /* @__PURE__ */ jsx("div", { id: countId, className: "border-t border-border px-3 py-1 text-right text-xs text-muted-foreground", children: labels.characterCount(state?.characters ?? 0, config.maxLength) }),
653
+ name && /* @__PURE__ */ jsx("input", { type: "hidden", name, value: formValue })
654
+ ]
655
+ }
656
+ );
657
+ }
658
+ );
659
+ function LinkPopover({
660
+ editor,
661
+ open,
662
+ onOpenChange,
663
+ disabled,
664
+ pressed,
665
+ labels,
666
+ apple,
667
+ returnToEditor,
668
+ onClosed
669
+ }) {
670
+ const inputId = React.useId();
671
+ const errorId = `${inputId}-error`;
672
+ const [url, setUrl] = React.useState("");
673
+ const [error, setError] = React.useState(false);
674
+ React.useEffect(() => {
675
+ if (open && editor) {
676
+ setUrl(editor.getAttributes("link").href ?? "");
677
+ setError(false);
678
+ }
679
+ }, [open, editor]);
680
+ const toEditor = React.useRef(false);
681
+ const finish = () => {
682
+ toEditor.current = true;
683
+ onOpenChange(false);
684
+ };
685
+ const onCloseAutoFocus = (event) => {
686
+ onClosed();
687
+ if (!toEditor.current && !returnToEditor) return;
688
+ toEditor.current = false;
689
+ event.preventDefault();
690
+ editor?.commands.focus();
691
+ };
692
+ const apply = (event) => {
693
+ event.preventDefault();
694
+ event.stopPropagation();
695
+ if (!editor) return;
696
+ const href = normalizeLinkUrl(url);
697
+ if (!href) {
698
+ setError(true);
699
+ return;
700
+ }
701
+ const chain = editor.chain().focus().extendMarkRange("link");
702
+ if (editor.state.selection.empty && !editor.isActive("link")) {
703
+ chain.insertContent({ type: "text", text: url.trim(), marks: [{ type: "link", attrs: { href } }] }).run();
704
+ } else {
705
+ chain.setLink({ href }).run();
706
+ }
707
+ finish();
708
+ };
709
+ const remove = () => {
710
+ editor?.chain().focus().extendMarkRange("link").unsetLink().run();
711
+ finish();
712
+ };
713
+ return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange, children: [
714
+ /* @__PURE__ */ jsx(Tooltip, { delayDuration: 300, content: /* @__PURE__ */ jsx(ShortcutHint, { label: labels.link, keys: ["Mod", "K"], apple }), children: /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
715
+ ToolbarButton,
716
+ {
717
+ "aria-label": labels.link,
718
+ "aria-pressed": pressed,
719
+ "aria-keyshortcuts": ariaKeys(["Mod", "K"], apple),
720
+ className: PRESSED_CLASS,
721
+ disabled,
722
+ children: /* @__PURE__ */ jsx(Link, { className: "size-4", "aria-hidden": true })
723
+ }
724
+ ) }) }),
725
+ /* @__PURE__ */ jsx(PopoverContent, { align: "start", className: "w-80", onCloseAutoFocus, children: /* @__PURE__ */ jsxs("form", { onSubmit: apply, className: "space-y-3", noValidate: true, children: [
726
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
727
+ /* @__PURE__ */ jsx(Label, { htmlFor: inputId, children: labels.linkUrl }),
728
+ /* @__PURE__ */ jsx(
729
+ Input,
730
+ {
731
+ id: inputId,
732
+ value: url,
733
+ autoFocus: true,
734
+ inputMode: "url",
735
+ autoComplete: "off",
736
+ placeholder: "https://",
737
+ "aria-invalid": error || void 0,
738
+ "aria-describedby": error ? errorId : void 0,
739
+ onChange: (e) => {
740
+ setUrl(e.target.value);
741
+ setError(false);
742
+ }
743
+ }
744
+ ),
745
+ error && /* @__PURE__ */ jsx("p", { id: errorId, role: "alert", className: "text-xs text-destructive", children: labels.linkInvalid })
746
+ ] }),
747
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
748
+ pressed && /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: remove, children: labels.linkRemove }),
749
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: finish, children: labels.cancel }),
750
+ /* @__PURE__ */ jsx(Button, { type: "submit", size: "sm", children: labels.linkApply })
751
+ ] })
752
+ ] }) })
753
+ ] });
754
+ }
755
+ function VideoPopover({ editor, open, onOpenChange, disabled, labels }) {
756
+ const urlId = React.useId();
757
+ const titleId = React.useId();
758
+ const errorId = `${urlId}-error`;
759
+ const [url, setUrl] = React.useState("");
760
+ const [title, setTitle] = React.useState("");
761
+ const [error, setError] = React.useState(false);
762
+ React.useEffect(() => {
763
+ if (open) {
764
+ setUrl("");
765
+ setTitle("");
766
+ setError(false);
767
+ }
768
+ }, [open]);
769
+ const toEditor = React.useRef(false);
770
+ const finish = () => {
771
+ toEditor.current = true;
772
+ onOpenChange(false);
773
+ };
774
+ const onCloseAutoFocus = (event) => {
775
+ if (!toEditor.current) return;
776
+ toEditor.current = false;
777
+ event.preventDefault();
778
+ editor?.commands.focus();
779
+ };
780
+ const insert = (event) => {
781
+ event.preventDefault();
782
+ event.stopPropagation();
783
+ if (!editor) return;
784
+ if (!parseVideoUrl(url)) {
785
+ setError(true);
786
+ return;
787
+ }
788
+ const ok = editor.chain().focus().setVideoEmbed({ url, ...title.trim() ? { title: title.trim() } : {} }).run();
789
+ if (!ok) {
790
+ setError(true);
791
+ return;
792
+ }
793
+ finish();
794
+ };
795
+ return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange, children: [
796
+ /* @__PURE__ */ jsx(Tooltip, { delayDuration: 300, content: labels.video, children: /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(ToolbarButton, { "aria-label": labels.video, className: "h-8 w-8 px-0", disabled, children: /* @__PURE__ */ jsx(Video, { className: "size-4", "aria-hidden": true }) }) }) }),
797
+ /* @__PURE__ */ jsx(PopoverContent, { align: "start", className: "w-80", onCloseAutoFocus, children: /* @__PURE__ */ jsxs("form", { onSubmit: insert, className: "space-y-3", noValidate: true, children: [
798
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
799
+ /* @__PURE__ */ jsx(Label, { htmlFor: urlId, children: labels.videoUrl }),
800
+ /* @__PURE__ */ jsx(
801
+ Input,
802
+ {
803
+ id: urlId,
804
+ value: url,
805
+ autoFocus: true,
806
+ inputMode: "url",
807
+ autoComplete: "off",
808
+ placeholder: "https://",
809
+ "aria-invalid": error || void 0,
810
+ "aria-describedby": error ? errorId : void 0,
811
+ onChange: (e) => {
812
+ setUrl(e.target.value);
813
+ setError(false);
814
+ }
815
+ }
816
+ ),
817
+ error && /* @__PURE__ */ jsx("p", { id: errorId, role: "alert", className: "text-xs text-destructive", children: labels.videoInvalid })
818
+ ] }),
819
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
820
+ /* @__PURE__ */ jsx(Label, { htmlFor: titleId, children: labels.videoTitle }),
821
+ /* @__PURE__ */ jsx(Input, { id: titleId, value: title, maxLength: 200, onChange: (e) => setTitle(e.target.value) })
822
+ ] }),
823
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
824
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: finish, children: labels.cancel }),
825
+ /* @__PURE__ */ jsx(Button, { type: "submit", size: "sm", children: labels.videoInsert })
826
+ ] })
827
+ ] }) })
828
+ ] });
829
+ }
830
+
831
+ export { RichTextEditor, VideoEmbed };
832
+ //# sourceMappingURL=rich-text-editor.js.map
833
+ //# sourceMappingURL=rich-text-editor.js.map