@sckyzo/cosmos-react 0.3.0 → 0.4.1

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.
@@ -0,0 +1,879 @@
1
+ "use client";
2
+ 'use strict';
3
+
4
+ var clsx = require('clsx');
5
+ var react = require('react');
6
+ var core = require('@tiptap/core');
7
+ var react$1 = require('@tiptap/react');
8
+ var menus = require('@tiptap/react/menus');
9
+ var extensions = require('@tiptap/extensions');
10
+ var StarterKit = require('@tiptap/starter-kit');
11
+ var jsxRuntime = require('react/jsx-runtime');
12
+ var lucideReact = require('lucide-react');
13
+ var state = require('@tiptap/pm/state');
14
+ var view = require('@tiptap/pm/view');
15
+ var cosmosReact = require('@sckyzo/cosmos-react');
16
+
17
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
18
+
19
+ var StarterKit__default = /*#__PURE__*/_interopDefault(StarterKit);
20
+
21
+ // src/editor/RichTextEditor.tsx
22
+ function useControllableState({
23
+ value,
24
+ defaultValue,
25
+ onChange,
26
+ name
27
+ }) {
28
+ const isControlled = value !== void 0;
29
+ const [internal, setInternal] = react.useState(defaultValue);
30
+ const current = isControlled ? value : internal;
31
+ const latest = react.useRef({ current, isControlled, onChange });
32
+ react.useInsertionEffect(() => {
33
+ latest.current = { current, isControlled, onChange };
34
+ });
35
+ const setValue = react.useCallback((next, ...args) => {
36
+ const state = latest.current;
37
+ const resolved = typeof next === "function" ? next(state.current) : next;
38
+ if (!state.isControlled) {
39
+ state.current = resolved;
40
+ setInternal(resolved);
41
+ }
42
+ state.onChange?.(resolved, ...args);
43
+ }, []);
44
+ useModeSwitchWarning(isControlled, name);
45
+ return [current, setValue, isControlled];
46
+ }
47
+ var isProductionBuild = (() => {
48
+ try {
49
+ return process.env.NODE_ENV === "production";
50
+ } catch {
51
+ return false;
52
+ }
53
+ })();
54
+ function useModeSwitchWarning(isControlled, name) {
55
+ const initial = react.useRef(isControlled);
56
+ const warned = react.useRef(false);
57
+ react.useEffect(() => {
58
+ if (isProductionBuild || warned.current || initial.current === isControlled) return;
59
+ warned.current = true;
60
+ const from = initial.current ? "controlled" : "uncontrolled";
61
+ const to = isControlled ? "controlled" : "uncontrolled";
62
+ console.warn(
63
+ `Cosmos: ${name} switched from ${from} to ${to}. Keep it one or the other for the life of the component: only \`undefined\` means uncontrolled.`
64
+ );
65
+ }, [isControlled, name]);
66
+ }
67
+
68
+ // src/chat/httpUrl.ts
69
+ var URL_PATTERN = /https?:\/\/[^\s<>"']+/g;
70
+ var WHOLE_URL = new RegExp(`^${URL_PATTERN.source}$`);
71
+ var isHttpUrl = (value) => WHOLE_URL.test(value);
72
+ var MAILTO = /^mailto:[^\s<>"'\\]+$/i;
73
+ var RELATIVE = /^(?:\/(?!\/)|\.\.?\/|#|\?)[^\s<>"'\\]*$/;
74
+ var isSafeLinkUrl = (value) => isHttpUrl(value) || MAILTO.test(value) || RELATIVE.test(value);
75
+ var FIELD_HEIGHT = {
76
+ sm: "h-ctrl-32",
77
+ md: "h-ctrl-36",
78
+ lg: "h-ctrl-44"
79
+ };
80
+ var FIELD_TEXT = {
81
+ sm: "text-xs",
82
+ md: "text-sm",
83
+ lg: "text-base"
84
+ };
85
+ var FIELD_PAD_X = {
86
+ sm: "px-2.5"};
87
+ var FIELD_PAD_Y = {
88
+ sm: "py-row-6"};
89
+ var FIELD_STATE = {
90
+ neutral: {
91
+ border: "border-gray-200 dark:border-gray-700",
92
+ focus: "focus:field-focus",
93
+ focusWithin: "focus-within:field-focus"
94
+ },
95
+ success: {
96
+ border: "border-green-600 dark:border-green-500/60",
97
+ focus: "focus:field-focus",
98
+ focusWithin: "focus-within:field-focus"
99
+ },
100
+ invalid: {
101
+ border: "border-red-400 dark:border-red-500/60",
102
+ focus: "focus:field-focus-error",
103
+ focusWithin: "focus-within:field-focus-error"
104
+ }
105
+ };
106
+ var fieldStateClass = ({
107
+ invalid = false,
108
+ success = false,
109
+ within = false
110
+ }) => {
111
+ const state = FIELD_STATE[invalid ? "invalid" : success ? "success" : "neutral"];
112
+ return clsx.clsx(state.border, within ? state.focusWithin : state.focus);
113
+ };
114
+ var fieldClass = ({
115
+ size,
116
+ invalid = false,
117
+ success = false,
118
+ within = false,
119
+ readOnly = false,
120
+ fixedHeight = true
121
+ }) => clsx.clsx(
122
+ "rounded-lg border transition-colors disabled:cursor-not-allowed disabled:opacity-50 dark:text-white",
123
+ readOnly ? "bg-gray-50 dark:bg-gray-900" : "bg-white dark:bg-gray-800",
124
+ FIELD_TEXT[size],
125
+ fixedHeight && FIELD_HEIGHT[size],
126
+ fieldStateClass({ invalid, success, within })
127
+ );
128
+ var FIELD_PLACEHOLDER = "placeholder:text-gray-500 dark:placeholder:text-gray-400";
129
+ var RequiredMark = ({ srText }) => /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
130
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": true, className: "ms-0.5 text-red-700 dark:text-red-400", children: "*" }),
131
+ srText && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "sr-only", children: srText })
132
+ ] });
133
+ var RICH_TEXT_ACTIONS = [
134
+ "bold",
135
+ "italic",
136
+ "strike",
137
+ "heading",
138
+ "bulletList",
139
+ "orderedList",
140
+ "blockquote",
141
+ "code",
142
+ "link",
143
+ "undo",
144
+ "redo"
145
+ ];
146
+ var BUBBLE_ACTIONS = [
147
+ "bold",
148
+ "italic",
149
+ "strike",
150
+ "code",
151
+ "link"
152
+ ];
153
+ var ACTION_SPECS = {
154
+ bold: {
155
+ icon: lucideReact.Bold,
156
+ label: "Bold",
157
+ shortcut: ["Mod", "B"],
158
+ isActive: (editor) => editor.isActive("bold"),
159
+ run: (editor) => editor.chain().toggleBold().run()
160
+ },
161
+ italic: {
162
+ icon: lucideReact.Italic,
163
+ label: "Italic",
164
+ shortcut: ["Mod", "I"],
165
+ isActive: (editor) => editor.isActive("italic"),
166
+ run: (editor) => editor.chain().toggleItalic().run()
167
+ },
168
+ strike: {
169
+ icon: lucideReact.Strikethrough,
170
+ label: "Strikethrough",
171
+ shortcut: ["Mod", "Shift", "S"],
172
+ isActive: (editor) => editor.isActive("strike"),
173
+ run: (editor) => editor.chain().toggleStrike().run()
174
+ },
175
+ heading: {
176
+ icon: lucideReact.Heading2,
177
+ label: "Heading",
178
+ shortcut: ["Mod", "Alt", "2"],
179
+ isActive: (editor) => editor.isActive("heading", { level: 2 }),
180
+ run: (editor) => editor.chain().toggleHeading({ level: 2 }).run()
181
+ },
182
+ bulletList: {
183
+ icon: lucideReact.List,
184
+ mirror: true,
185
+ label: "Bulleted list",
186
+ shortcut: ["Mod", "Shift", "8"],
187
+ isActive: (editor) => editor.isActive("bulletList"),
188
+ run: (editor) => editor.chain().toggleBulletList().run()
189
+ },
190
+ orderedList: {
191
+ icon: lucideReact.ListOrdered,
192
+ mirror: true,
193
+ label: "Numbered list",
194
+ shortcut: ["Mod", "Shift", "7"],
195
+ isActive: (editor) => editor.isActive("orderedList"),
196
+ run: (editor) => editor.chain().toggleOrderedList().run()
197
+ },
198
+ blockquote: {
199
+ icon: lucideReact.Quote,
200
+ mirror: true,
201
+ label: "Quote",
202
+ shortcut: ["Mod", "Shift", "B"],
203
+ isActive: (editor) => editor.isActive("blockquote"),
204
+ run: (editor) => editor.chain().toggleBlockquote().run()
205
+ },
206
+ code: {
207
+ icon: lucideReact.Code,
208
+ label: "Code",
209
+ shortcut: ["Mod", "E"],
210
+ isActive: (editor) => editor.isActive("code"),
211
+ run: (editor) => editor.chain().toggleCode().run()
212
+ },
213
+ link: {
214
+ icon: lucideReact.Link,
215
+ label: "Link",
216
+ shortcut: ["Mod", "K"],
217
+ isActive: (editor) => editor.isActive("link")
218
+ },
219
+ undo: {
220
+ icon: lucideReact.Undo2,
221
+ mirror: true,
222
+ label: "Undo",
223
+ shortcut: ["Mod", "Z"],
224
+ isActive: () => false,
225
+ isIdle: (editor) => !editor.can().undo(),
226
+ run: (editor) => editor.chain().undo().run()
227
+ },
228
+ redo: {
229
+ icon: lucideReact.Redo2,
230
+ mirror: true,
231
+ label: "Redo",
232
+ shortcut: ["Mod", "Shift", "Z"],
233
+ isActive: () => false,
234
+ isIdle: (editor) => !editor.can().redo(),
235
+ run: (editor) => editor.chain().redo().run()
236
+ }
237
+ };
238
+ var isApple = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
239
+ var KEY_NAMES = {
240
+ Mod: ["\u2318", "Ctrl"],
241
+ Shift: ["\u21E7", "Shift"],
242
+ Alt: ["\u2325", "Alt"]
243
+ };
244
+ var shortcutKeys = (shortcut) => shortcut.map((key2) => KEY_NAMES[key2] ? KEY_NAMES[key2][isApple() ? 0 : 1] : key2);
245
+ var ariaShortcut = (shortcut) => ["Control", "Meta"].map((mod) => shortcut.map((key2) => key2 === "Mod" ? mod : key2).join("+")).join(" ");
246
+ var RTL_LETTER = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}\p{Script=Samaritan}\p{Script=Mandaic}]/u;
247
+ var LETTER = /\p{L}/u;
248
+ var firstLetterDirection = (text) => {
249
+ const letter = LETTER.exec(text)?.[0];
250
+ if (letter === void 0) return void 0;
251
+ return RTL_LETTER.test(letter) ? "rtl" : "ltr";
252
+ };
253
+ var CONTAINERS = /* @__PURE__ */ new Set(["bulletList", "orderedList", "listItem", "blockquote"]);
254
+ var directionOf = (node) => {
255
+ let direction;
256
+ node.descendants((child) => {
257
+ if (direction) return false;
258
+ if (child.isText) direction = firstLetterDirection(child.text ?? "");
259
+ return !direction;
260
+ });
261
+ return direction;
262
+ };
263
+ var decorate = (doc) => {
264
+ const decorations = [];
265
+ doc.descendants((node, pos) => {
266
+ if (node.isTextblock) return false;
267
+ if (!CONTAINERS.has(node.type.name)) return true;
268
+ const dir = directionOf(node);
269
+ if (dir) decorations.push(view.Decoration.node(pos, pos + node.nodeSize, { dir }));
270
+ return true;
271
+ });
272
+ return view.DecorationSet.create(doc, decorations);
273
+ };
274
+ var key = new state.PluginKey("blockDirection");
275
+ var BlockDirection = core.Extension.create({
276
+ name: "blockDirection",
277
+ addProseMirrorPlugins() {
278
+ return [
279
+ new state.Plugin({
280
+ key,
281
+ state: {
282
+ init: (_, { doc }) => decorate(doc),
283
+ apply: (tr, decorations) => tr.docChanged ? decorate(tr.doc) : decorations
284
+ },
285
+ props: { decorations: (state) => key.getState(state) }
286
+ })
287
+ ];
288
+ }
289
+ });
290
+ var directionOf2 = (element, read = (el) => getComputedStyle(el)) => element && read(element).direction === "rtl" ? "rtl" : "ltr";
291
+
292
+ // src/ui/mergeRefs.ts
293
+ function setRef(ref, node) {
294
+ if (typeof ref === "function") ref(node);
295
+ else if (ref) ref.current = node;
296
+ }
297
+ function mergeRefs(...refs) {
298
+ return (node) => {
299
+ const cleanups = refs.map((ref) => {
300
+ if (typeof ref === "function") {
301
+ const cleanup = ref(node);
302
+ return typeof cleanup === "function" ? cleanup : () => ref(null);
303
+ }
304
+ setRef(ref, node);
305
+ return () => setRef(ref, null);
306
+ });
307
+ return () => {
308
+ for (const cleanup of cleanups) cleanup();
309
+ };
310
+ };
311
+ }
312
+
313
+ // src/editor/linkUrl.ts
314
+ var BARE_HOST = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?:[/?#]\S*)?$/i;
315
+ var EMAIL = /^[^\s@<>"'\\:/]+@(?:[a-z0-9-]+\.)+[a-z]{2,}$/i;
316
+ var normalizeLinkUrl = (input) => {
317
+ const value = input.trim();
318
+ if (isSafeLinkUrl(value)) return value;
319
+ if (EMAIL.test(value)) return `mailto:${value}`;
320
+ if (BARE_HOST.test(value)) return `https://${value}`;
321
+ return void 0;
322
+ };
323
+ function LinkForm({ editor, labels, onDone, className }) {
324
+ const errorId = react.useId();
325
+ const [value, setValue] = react.useState(() => editor.getAttributes("link").href ?? "");
326
+ const [invalid, setInvalid] = react.useState(false);
327
+ const hasLink = editor.isActive("link");
328
+ const apply = () => {
329
+ const href = normalizeLinkUrl(value);
330
+ if (!href) {
331
+ setInvalid(true);
332
+ return;
333
+ }
334
+ const chain = editor.chain().focus();
335
+ if (editor.state.selection.empty && !hasLink) {
336
+ chain.insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] });
337
+ } else {
338
+ chain.extendMarkRange("link").setLink({ href });
339
+ }
340
+ chain.run();
341
+ onDone();
342
+ };
343
+ const remove = () => {
344
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
345
+ onDone();
346
+ };
347
+ return /* @__PURE__ */ jsxRuntime.jsxs(
348
+ "div",
349
+ {
350
+ role: "group",
351
+ "aria-label": labels.linkDialog,
352
+ className: clsx.clsx("flex flex-col gap-2", className),
353
+ children: [
354
+ /* @__PURE__ */ jsxRuntime.jsx(
355
+ "input",
356
+ {
357
+ type: "text",
358
+ inputMode: "url",
359
+ autoFocus: true,
360
+ autoComplete: "off",
361
+ spellCheck: false,
362
+ dir: "ltr",
363
+ "aria-label": labels.linkUrl,
364
+ "aria-invalid": invalid || void 0,
365
+ "aria-describedby": invalid ? errorId : void 0,
366
+ placeholder: "https://",
367
+ value,
368
+ onChange: (e) => {
369
+ setValue(e.target.value);
370
+ setInvalid(false);
371
+ },
372
+ onKeyDown: (e) => {
373
+ if (e.key === "Enter") {
374
+ e.preventDefault();
375
+ apply();
376
+ } else if (e.key === "Escape") {
377
+ e.preventDefault();
378
+ e.stopPropagation();
379
+ onDone();
380
+ editor.commands.focus();
381
+ }
382
+ },
383
+ className: clsx.clsx(
384
+ "block w-full",
385
+ fieldClass({ size: "sm", invalid, fixedHeight: false }),
386
+ FIELD_PAD_X.sm,
387
+ FIELD_PAD_Y.sm,
388
+ FIELD_PLACEHOLDER
389
+ )
390
+ }
391
+ ),
392
+ invalid && /* @__PURE__ */ jsxRuntime.jsx("span", { id: errorId, className: "text-xs text-red-700 dark:text-red-400", children: labels.linkInvalid }),
393
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-end gap-2", children: [
394
+ hasLink && /* @__PURE__ */ jsxRuntime.jsx(cosmosReact.Button, { type: "button", variant: "ghost", size: "xs", onClick: remove, children: labels.linkRemove }),
395
+ /* @__PURE__ */ jsxRuntime.jsx(cosmosReact.Button, { type: "button", size: "xs", onClick: apply, children: labels.linkApply })
396
+ ] })
397
+ ]
398
+ }
399
+ );
400
+ }
401
+ var BUTTON = "focus-visible:focus-indicator flex size-ctrl-28 shrink-0 items-center justify-center rounded-md transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-40";
402
+ var PRESSED = "bg-brand-500/10 text-brand-600 dark:bg-brand-500/15 dark:text-brand-400";
403
+ var IDLE = "text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 aria-disabled:hover:bg-transparent dark:aria-disabled:hover:bg-transparent";
404
+ function FormattingToolbar({
405
+ editor,
406
+ actions,
407
+ labels,
408
+ disabled,
409
+ linkOpen,
410
+ onLinkOpenChange,
411
+ linkMode,
412
+ onEscape,
413
+ className,
414
+ ref
415
+ }) {
416
+ const root = react.useRef(null);
417
+ const [stop, setStop] = react.useState(0);
418
+ const state = react$1.useEditorState({
419
+ editor,
420
+ selector: ({ editor: current }) => Object.fromEntries(
421
+ actions.map((action) => [
422
+ action,
423
+ {
424
+ active: ACTION_SPECS[action].isActive(current),
425
+ idle: ACTION_SPECS[action].isIdle?.(current) ?? false
426
+ }
427
+ ])
428
+ )
429
+ });
430
+ const rootRef = react.useMemo(() => mergeRefs(root, ref), [ref]);
431
+ const tabStop = Math.min(stop, actions.length - 1);
432
+ const buttons = () => Array.from(root.current?.querySelectorAll("button[data-rt-action]") ?? []);
433
+ const onKeyDown = (event) => {
434
+ if (event.key === "Escape" && onEscape) {
435
+ event.preventDefault();
436
+ onEscape();
437
+ return;
438
+ }
439
+ if (!(event.target instanceof HTMLButtonElement)) return;
440
+ const list = buttons();
441
+ const at = list.indexOf(event.target);
442
+ const toEnd = directionOf2(root.current) === "rtl" ? "ArrowLeft" : "ArrowRight";
443
+ const next = event.key === toEnd ? (at + 1) % list.length : event.key === (toEnd === "ArrowRight" ? "ArrowLeft" : "ArrowRight") ? (at - 1 + list.length) % list.length : event.key === "Home" ? 0 : event.key === "End" ? list.length - 1 : -1;
444
+ if (next < 0) return;
445
+ event.preventDefault();
446
+ list[next]?.focus();
447
+ };
448
+ const linkField = /* @__PURE__ */ jsxRuntime.jsx(
449
+ LinkForm,
450
+ {
451
+ editor,
452
+ labels,
453
+ onDone: () => onLinkOpenChange(false),
454
+ className: linkMode === "inline" ? "w-64 p-2" : void 0
455
+ }
456
+ );
457
+ return /* @__PURE__ */ jsxRuntime.jsx(
458
+ "div",
459
+ {
460
+ ref: rootRef,
461
+ role: "toolbar",
462
+ "aria-label": labels.toolbar,
463
+ "aria-orientation": "horizontal",
464
+ onKeyDown,
465
+ className: clsx.clsx("flex flex-wrap items-center gap-0.5", className),
466
+ children: linkMode === "inline" && linkOpen ? linkField : actions.map((action, index) => {
467
+ const spec = ACTION_SPECS[action];
468
+ const { active, idle } = state[action];
469
+ const Icon = spec.icon;
470
+ const inert = disabled || idle;
471
+ const common = {
472
+ "data-rt-action": action,
473
+ "aria-label": labels[action],
474
+ "aria-keyshortcuts": ariaShortcut(spec.shortcut),
475
+ "aria-pressed": active,
476
+ "aria-disabled": inert || void 0,
477
+ tabIndex: index === tabStop ? 0 : -1,
478
+ onFocus: () => setStop(index),
479
+ // Keeps the focus, and so the selection, in the text on a mouse press.
480
+ onMouseDown: (e) => e.preventDefault(),
481
+ className: clsx.clsx(BUTTON, active ? PRESSED : IDLE)
482
+ };
483
+ const icon = /* @__PURE__ */ jsxRuntime.jsx(Icon, { "aria-hidden": true, className: clsx.clsx("h-4 w-4", spec.mirror && "rtl:-scale-x-100") });
484
+ const tooltip = (child) => /* @__PURE__ */ jsxRuntime.jsx(
485
+ cosmosReact.Tooltip,
486
+ {
487
+ content: labels[action],
488
+ shortcut: shortcutKeys(spec.shortcut),
489
+ position: "top",
490
+ children: child
491
+ },
492
+ action
493
+ );
494
+ if (action === "link" && linkMode === "popover") {
495
+ return /* @__PURE__ */ jsxRuntime.jsxs(cosmosReact.Popover, { open: linkOpen && !disabled, onOpenChange: onLinkOpenChange, children: [
496
+ tooltip(/* @__PURE__ */ jsxRuntime.jsx(cosmosReact.Popover.Trigger, { ...common, children: icon })),
497
+ /* @__PURE__ */ jsxRuntime.jsx(
498
+ cosmosReact.Popover.Content,
499
+ {
500
+ "aria-label": labels.linkDialog,
501
+ position: "bottom",
502
+ width: "w-72",
503
+ padding: "p-3",
504
+ children: linkField
505
+ }
506
+ )
507
+ ] }, action);
508
+ }
509
+ if (action === "link") {
510
+ return tooltip(
511
+ /* @__PURE__ */ jsxRuntime.jsx(
512
+ "button",
513
+ {
514
+ type: "button",
515
+ ...common,
516
+ onClick: () => !disabled && onLinkOpenChange(true),
517
+ children: icon
518
+ }
519
+ )
520
+ );
521
+ }
522
+ return tooltip(
523
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", ...common, onClick: () => !inert && spec.run?.(editor), children: icon })
524
+ );
525
+ })
526
+ }
527
+ );
528
+ }
529
+
530
+ // src/editor/labels.ts
531
+ var DEFAULTS = {
532
+ toolbar: "Text formatting",
533
+ linkUrl: "Link URL",
534
+ linkDialog: "Edit link",
535
+ linkApply: "Apply",
536
+ linkRemove: "Remove link",
537
+ linkInvalid: "Enter an http(s), mailto or relative link."
538
+ };
539
+ var resolveLabels = (labels = {}) => {
540
+ const actions = Object.fromEntries(
541
+ Object.keys(ACTION_SPECS).map((action) => [
542
+ action,
543
+ labels[action] ?? ACTION_SPECS[action].label
544
+ ])
545
+ );
546
+ return {
547
+ ...actions,
548
+ toolbar: labels.toolbar ?? DEFAULTS.toolbar,
549
+ linkUrl: labels.linkUrl ?? DEFAULTS.linkUrl,
550
+ linkDialog: labels.linkDialog ?? DEFAULTS.linkDialog,
551
+ linkApply: labels.linkApply ?? DEFAULTS.linkApply,
552
+ linkRemove: labels.linkRemove ?? DEFAULTS.linkRemove,
553
+ linkInvalid: labels.linkInvalid ?? DEFAULTS.linkInvalid
554
+ };
555
+ };
556
+ var NO_PROPS = { format: "html", setCurrent: () => {
557
+ }, openLink: () => false };
558
+ var createStore = (initial) => {
559
+ let value = initial;
560
+ return {
561
+ get: () => value,
562
+ set: (next) => {
563
+ value = next;
564
+ }
565
+ };
566
+ };
567
+ var EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };
568
+ var readContent = (editor, format) => format === "json" ? editor.getJSON() : editor.isEmpty ? "" : editor.getHTML();
569
+ var countCharacters = (editor) => editor.storage.characterCount?.characters() ?? 0;
570
+ var sameContent = (a, b) => typeof a === "string" || typeof b === "string" ? a === b : JSON.stringify(a) === JSON.stringify(b);
571
+ var LinkShortcut = core.Extension.create({
572
+ name: "linkShortcut",
573
+ addOptions: () => ({ open: () => false }),
574
+ addKeyboardShortcuts() {
575
+ return { "Mod-k": () => this.options.open() };
576
+ }
577
+ });
578
+ var CONTENT_CLASS = [
579
+ "min-w-0 outline-hidden break-words",
580
+ "[&>*+*]:mt-2",
581
+ // Each block follows the direction of what is typed in it, as a Textarea's paragraphs do
582
+ // (`FIELD_PLAINTEXT`); lists and quotes take a `dir` from `BlockDirection`.
583
+ "[&_p]:[unicode-bidi:plaintext] [&_h2]:[unicode-bidi:plaintext] [&_h3]:[unicode-bidi:plaintext]",
584
+ "[&_h2]:text-lg [&_h2]:leading-snug [&_h2]:font-semibold",
585
+ "[&_h3]:text-base [&_h3]:leading-snug [&_h3]:font-semibold",
586
+ "[&_ul]:list-disc [&_ul]:ps-5 [&_ol]:list-decimal [&_ol]:ps-5 [&_li>p]:my-0",
587
+ "[&_blockquote]:border-s-4 [&_blockquote]:border-gray-300 [&_blockquote]:ps-3 [&_blockquote]:text-gray-600",
588
+ "dark:[&_blockquote]:border-gray-600 dark:[&_blockquote]:text-gray-300",
589
+ "[&_code]:rounded-sm [&_code]:bg-gray-100 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.9em]",
590
+ "dark:[&_code]:bg-gray-800",
591
+ "[&_a]:text-brand-600 dark:[&_a]:text-brand-400 [&_a]:underline [&_a]:underline-offset-2",
592
+ "[&_p.is-editor-empty:first-child]:before:pointer-events-none [&_p.is-editor-empty:first-child]:before:float-start [&_p.is-editor-empty:first-child]:before:h-0",
593
+ "[&_p.is-editor-empty:first-child]:before:text-gray-500 dark:[&_p.is-editor-empty:first-child]:before:text-gray-400",
594
+ "[&_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]"
595
+ ].join(" ");
596
+ function RichTextEditor({
597
+ format = "html",
598
+ value,
599
+ defaultValue,
600
+ onChange,
601
+ variant = "toolbar",
602
+ toolbar,
603
+ labels: labelProps,
604
+ label,
605
+ description,
606
+ error,
607
+ required,
608
+ labelTrailing,
609
+ size = "md",
610
+ placeholder,
611
+ readOnly = false,
612
+ disabled = false,
613
+ maxLength,
614
+ rows = 4,
615
+ autoFocus = false,
616
+ id,
617
+ name,
618
+ "aria-label": ariaLabel,
619
+ className
620
+ }) {
621
+ const generatedId = react.useId();
622
+ const fieldId = id ?? generatedId;
623
+ const labelId = `${fieldId}-label`;
624
+ const descId = `${fieldId}-desc`;
625
+ const labels = react.useMemo(() => resolveLabels(labelProps), [labelProps]);
626
+ const actions = toolbar ?? (variant === "bubble" ? BUBBLE_ACTIONS : RICH_TEXT_ACTIONS);
627
+ const hasLinkField = variant !== "minimal" && actions.includes("link");
628
+ const editable = !readOnly && !disabled;
629
+ const [current, setCurrent] = useControllableState({
630
+ value,
631
+ defaultValue: defaultValue ?? (format === "json" ? EMPTY_DOC : ""),
632
+ onChange: (next) => onChange?.(next),
633
+ name: "RichTextEditor.value"
634
+ });
635
+ const [linkOpen, setLinkOpen] = react.useState(false);
636
+ const [characters, setCharacters] = react.useState(0);
637
+ const [latest] = react.useState(() => createStore(NO_PROPS));
638
+ const [echoes] = react.useState(() => createStore([]));
639
+ react.useInsertionEffect(() => {
640
+ latest.set({
641
+ format,
642
+ placeholder,
643
+ setCurrent,
644
+ openLink: () => {
645
+ if (!hasLinkField || !editable) return false;
646
+ setLinkOpen(true);
647
+ return true;
648
+ }
649
+ });
650
+ });
651
+ const editor = react$1.useEditor(
652
+ {
653
+ // Created after the first render: the server and the first client render agree.
654
+ immediatelyRender: false,
655
+ shouldRerenderOnTransaction: false,
656
+ editable,
657
+ autofocus: autoFocus ? "end" : false,
658
+ content: current,
659
+ extensions: [
660
+ StarterKit__default.default.configure({
661
+ heading: { levels: [2, 3] },
662
+ // What the editor doesn't offer is out of the schema, so no paste or value brings it in.
663
+ codeBlock: false,
664
+ horizontalRule: false,
665
+ underline: false,
666
+ trailingNode: false,
667
+ link: {
668
+ openOnClick: false,
669
+ markdownLinks: true,
670
+ defaultProtocol: "https",
671
+ isAllowedUri: (url) => isSafeLinkUrl(url)
672
+ }
673
+ }),
674
+ extensions.Placeholder.configure({ placeholder: () => latest.get().placeholder ?? "" }),
675
+ ...maxLength !== void 0 ? [extensions.CharacterCount.configure({ limit: maxLength })] : [],
676
+ LinkShortcut.configure({ open: () => latest.get().openLink() }),
677
+ BlockDirection
678
+ ],
679
+ onCreate: ({ editor: created }) => setCharacters(countCharacters(created)),
680
+ onTransaction: ({ editor: changed }) => setCharacters(countCharacters(changed)),
681
+ onUpdate: ({ editor: updated }) => {
682
+ const content = readContent(updated, latest.get().format);
683
+ echoes.get().push(content);
684
+ latest.get().setCurrent(content);
685
+ }
686
+ },
687
+ [maxLength, format]
688
+ );
689
+ react.useEffect(() => {
690
+ if (!editor || editor.isDestroyed) return;
691
+ const queue = echoes.get();
692
+ const echo = queue.findIndex((sent) => sameContent(sent, current));
693
+ if (echo >= 0) {
694
+ queue.splice(0, echo + 1);
695
+ return;
696
+ }
697
+ queue.length = 0;
698
+ if (!sameContent(readContent(editor, format), current)) {
699
+ editor.commands.setContent(current, { emitUpdate: false });
700
+ }
701
+ }, [editor, current, format, echoes]);
702
+ react.useEffect(() => {
703
+ if (editor && !editor.isDestroyed && editor.isEditable !== editable) {
704
+ editor.setEditable(editable, false);
705
+ }
706
+ }, [editor, editable]);
707
+ const overLimit = maxLength !== void 0 && characters > maxLength;
708
+ const invalid = !!error || overLimit;
709
+ const hasDesc = !!(error || description);
710
+ const attributes = react.useMemo(
711
+ () => ({
712
+ id: fieldId,
713
+ class: clsx.clsx(CONTENT_CLASS, "px-3 py-2"),
714
+ style: `min-height: ${rows * 1.5 + 1}em`,
715
+ "aria-multiline": "true",
716
+ ...label ? { "aria-labelledby": labelId } : ariaLabel ? { "aria-label": ariaLabel } : {},
717
+ ...hasDesc ? { "aria-describedby": descId } : {},
718
+ ...invalid ? { "aria-invalid": "true" } : {},
719
+ ...required ? { "aria-required": "true" } : {},
720
+ ...readOnly ? { "aria-readonly": "true" } : {},
721
+ ...disabled ? { "aria-disabled": "true" } : {},
722
+ // A read-only text is still reachable by keyboard, to scroll it and follow its links.
723
+ ...!editable && !disabled ? { tabindex: "0" } : {}
724
+ }),
725
+ [
726
+ fieldId,
727
+ rows,
728
+ label,
729
+ labelId,
730
+ ariaLabel,
731
+ hasDesc,
732
+ descId,
733
+ invalid,
734
+ required,
735
+ readOnly,
736
+ disabled,
737
+ editable
738
+ ]
739
+ );
740
+ react.useEffect(() => {
741
+ if (editor && !editor.isDestroyed) editor.setOptions({ editorProps: { attributes } });
742
+ }, [editor, attributes]);
743
+ const bubbleRef = react.useRef(null);
744
+ const onFrameKeyDown = (event) => {
745
+ if (variant !== "bubble" || !event.altKey || event.key !== "F10") return;
746
+ const stop = bubbleRef.current?.querySelector('button[tabindex="0"]');
747
+ if (!stop) return;
748
+ event.preventDefault();
749
+ stop.focus();
750
+ };
751
+ const showToolbar = variant === "toolbar" && !readOnly && editor;
752
+ const counter = maxLength !== void 0;
753
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: clsx.clsx("block", className), children: [
754
+ label && // The editable text isn't a labelable element, so the label names it by id.
755
+ /* @__PURE__ */ jsxRuntime.jsxs(
756
+ "span",
757
+ {
758
+ id: labelId,
759
+ onClick: () => editor?.commands.focus(),
760
+ className: "mb-1.5 flex items-center gap-1.5 text-sm font-medium text-gray-700 dark:text-gray-300",
761
+ children: [
762
+ required ? /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
763
+ label,
764
+ /* @__PURE__ */ jsxRuntime.jsx(RequiredMark, {})
765
+ ] }) : label,
766
+ labelTrailing
767
+ ]
768
+ }
769
+ ),
770
+ /* @__PURE__ */ jsxRuntime.jsxs(
771
+ "div",
772
+ {
773
+ onKeyDown: onFrameKeyDown,
774
+ className: clsx.clsx(
775
+ "block w-full",
776
+ fieldClass({
777
+ size,
778
+ invalid,
779
+ within: true,
780
+ readOnly: readOnly || disabled,
781
+ fixedHeight: false
782
+ }),
783
+ disabled && "cursor-not-allowed opacity-50"
784
+ ),
785
+ children: [
786
+ showToolbar && /* @__PURE__ */ jsxRuntime.jsx(
787
+ FormattingToolbar,
788
+ {
789
+ editor,
790
+ actions,
791
+ labels,
792
+ disabled,
793
+ linkOpen,
794
+ onLinkOpenChange: setLinkOpen,
795
+ linkMode: "popover",
796
+ className: "border-b border-gray-200 p-1 dark:border-gray-700"
797
+ }
798
+ ),
799
+ /* @__PURE__ */ jsxRuntime.jsx(react$1.EditorContent, { editor }),
800
+ variant === "bubble" && editor && editable && /* @__PURE__ */ jsxRuntime.jsx(
801
+ menus.BubbleMenu,
802
+ {
803
+ editor,
804
+ updateDelay: 0,
805
+ options: {
806
+ strategy: "fixed",
807
+ placement: "top",
808
+ offset: 8,
809
+ onHide: () => setLinkOpen(false)
810
+ },
811
+ className: "z-[9999]",
812
+ children: /* @__PURE__ */ jsxRuntime.jsx(
813
+ FormattingToolbar,
814
+ {
815
+ ref: bubbleRef,
816
+ editor,
817
+ actions,
818
+ labels,
819
+ disabled,
820
+ linkOpen,
821
+ onLinkOpenChange: setLinkOpen,
822
+ linkMode: "inline",
823
+ onEscape: () => {
824
+ setLinkOpen(false);
825
+ editor.commands.focus();
826
+ },
827
+ className: "rounded-lg border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
828
+ }
829
+ )
830
+ }
831
+ )
832
+ ]
833
+ }
834
+ ),
835
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mt-1 flex items-start justify-between gap-2", children: [
836
+ /* @__PURE__ */ jsxRuntime.jsx(
837
+ "span",
838
+ {
839
+ id: descId,
840
+ className: clsx.clsx(
841
+ "block min-h-[1rem] text-xs",
842
+ error ? "text-red-700 dark:text-red-400" : "text-gray-500 dark:text-gray-400"
843
+ ),
844
+ children: error ?? description ?? ""
845
+ }
846
+ ),
847
+ counter && // A count is a code: "12 / 100" reads in this order whatever the page's direction.
848
+ /* @__PURE__ */ jsxRuntime.jsxs(
849
+ "span",
850
+ {
851
+ dir: "ltr",
852
+ "aria-live": "polite",
853
+ className: clsx.clsx(
854
+ "shrink-0 font-mono text-xs tabular-nums",
855
+ overLimit ? "text-red-700 dark:text-red-400" : "text-gray-500 dark:text-gray-400"
856
+ ),
857
+ children: [
858
+ characters,
859
+ " / ",
860
+ maxLength
861
+ ]
862
+ }
863
+ )
864
+ ] }),
865
+ name && /* @__PURE__ */ jsxRuntime.jsx(
866
+ "input",
867
+ {
868
+ type: "hidden",
869
+ name,
870
+ value: typeof current === "string" ? current : JSON.stringify(current)
871
+ }
872
+ )
873
+ ] });
874
+ }
875
+
876
+ exports.RICH_TEXT_ACTIONS = RICH_TEXT_ACTIONS;
877
+ exports.RichTextEditor = RichTextEditor;
878
+ //# sourceMappingURL=index.cjs.map
879
+ //# sourceMappingURL=index.cjs.map