@exsto/ds 0.0.1-rc.5 → 0.0.1-rc.7

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 (27) hide show
  1. package/README.md +33 -0
  2. package/dist/chunks/toggle-C2c5oDaE.js +629 -0
  3. package/dist/chunks/toggle-C2c5oDaE.js.map +1 -0
  4. package/dist/ds/src/rich-text-editor.d.ts +8 -0
  5. package/dist/index.js +174 -576
  6. package/dist/index.js.map +1 -1
  7. package/dist/rich-text-editor.js +846 -0
  8. package/dist/rich-text-editor.js.map +1 -0
  9. package/dist/styles.css +221 -51
  10. package/dist/ui/src/components/edyn/date-range-picker/date-range-picker.d.ts +7 -1
  11. package/dist/ui/src/components/edyn/multi-select/multi-select.d.ts +10 -2
  12. package/dist/ui/src/components/edyn/rich-text-content/allow-list.d.ts +11 -0
  13. package/dist/ui/src/components/edyn/rich-text-content/index.d.ts +2 -0
  14. package/dist/ui/src/components/edyn/rich-text-content/rich-text-content.d.ts +22 -0
  15. package/dist/ui/src/components/edyn/rich-text-content/rich-text-content.test.d.ts +8 -0
  16. package/dist/ui/src/components/edyn/rich-text-editor/extensions.d.ts +18 -0
  17. package/dist/ui/src/components/edyn/rich-text-editor/index.d.ts +3 -0
  18. package/dist/ui/src/components/edyn/rich-text-editor/mention-list.d.ts +17 -0
  19. package/dist/ui/src/components/edyn/rich-text-editor/rich-text-editor.d.ts +53 -0
  20. package/dist/ui/src/components/edyn/rich-text-editor/rich-text-editor.test.d.ts +6 -0
  21. package/dist/ui/src/components/edyn/rich-text-editor/serialize.d.ts +13 -0
  22. package/dist/ui/src/components/edyn/rich-text-editor/toolbar.d.ts +20 -0
  23. package/dist/ui/src/components/edyn/rich-text-editor/types.d.ts +68 -0
  24. package/dist/ui/src/components/ui/popover/popover.d.ts +1 -1
  25. package/dist/ui/src/components/ui/progress/progress.d.ts +38 -4
  26. package/dist/ui/src/index.d.ts +1 -0
  27. package/package.json +21 -1
@@ -0,0 +1,846 @@
1
+ import { B as cn, C as RICH_TEXT_ATTRIBUTES, E as isAllowedRichTextHref, L as PopoverTrigger, M as Popover, N as PopoverContent, O as popupItemClass, R as Button, T as RICH_TEXT_LINK_PROTOCOLS, _ as DropdownMenuTrigger, a as DropdownMenuContent, k as ScrollArea, n as toggleVariants, r as DropdownMenu, s as DropdownMenuItem, v as Input, w as RICH_TEXT_ELEMENTS, y as RICH_TEXT_BODY_CLASS } from "./chunks/toggle-C2c5oDaE.js";
2
+ import { useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ import { IconBold, IconItalic, IconLink, IconList, IconListNumbers, IconLoader2, IconSeparatorHorizontal, IconUnderline, IconVariable } from "@tabler/icons-react";
5
+ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
6
+ import { Extension, mergeAttributes } from "@tiptap/core";
7
+ import { Bold } from "@tiptap/extension-bold";
8
+ import { Document } from "@tiptap/extension-document";
9
+ import { HardBreak } from "@tiptap/extension-hard-break";
10
+ import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
11
+ import { Italic } from "@tiptap/extension-italic";
12
+ import { Link } from "@tiptap/extension-link";
13
+ import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
14
+ import { Mention } from "@tiptap/extension-mention";
15
+ import { Paragraph } from "@tiptap/extension-paragraph";
16
+ import { Text } from "@tiptap/extension-text";
17
+ import { Underline } from "@tiptap/extension-underline";
18
+ import { UndoRedo } from "@tiptap/extensions";
19
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
20
+ import { DOMSerializer } from "@tiptap/pm/model";
21
+ import { Toolbar } from "@base-ui/react/toolbar";
22
+ //#region ../ui/src/components/edyn/rich-text-editor/serialize.ts
23
+ var MENTION_MARK = "data-rich-text-mention";
24
+ var defaultMentionToText = (item) => `@${item.id}`;
25
+ /**
26
+ * The document as HTML. Not `editor.getHTML()`: that would write a mention as its chip element, and
27
+ * the HTML must carry the plain text the app chose (`@person@example.test`) and nothing else.
28
+ */
29
+ var serializeDocument = (doc, toText) => {
30
+ const schema = doc.type.schema;
31
+ const nodes = {
32
+ ...DOMSerializer.nodesFromSchema(schema),
33
+ mention: (node) => [
34
+ "span",
35
+ { [MENTION_MARK]: "" },
36
+ toText({
37
+ id: String(node.attrs.id ?? ""),
38
+ label: String(node.attrs.label ?? "")
39
+ })
40
+ ]
41
+ };
42
+ const serializer = new DOMSerializer(nodes, DOMSerializer.marksFromSchema(schema));
43
+ const container = document.createElement("div");
44
+ container.appendChild(serializer.serializeFragment(doc.content));
45
+ for (const element of Array.from(container.querySelectorAll(`[${MENTION_MARK}]`))) element.replaceWith(document.createTextNode(element.textContent ?? ""));
46
+ return container.innerHTML;
47
+ };
48
+ /** No text and no mention. A document of blank paragraphs, spaces and line breaks is empty too. */
49
+ var isDocumentEmpty = (doc) => {
50
+ if (doc.textContent.trim() !== "") return false;
51
+ let hasMention = false;
52
+ doc.descendants((node) => {
53
+ if (node.type.name === "mention") hasMention = true;
54
+ return !hasMention;
55
+ });
56
+ return !hasMention;
57
+ };
58
+ /** Characters the HTML text will have: a mention counts as the text it is written as. */
59
+ var countCharacters = (doc, toText) => {
60
+ let total = 0;
61
+ doc.descendants((node) => {
62
+ if (node.isText) total += node.text?.length ?? 0;
63
+ else if (node.type.name === "mention") total += toText({
64
+ id: String(node.attrs.id ?? ""),
65
+ label: String(node.attrs.label ?? "")
66
+ }).length;
67
+ else if (node.type.name === "hardBreak") total += 1;
68
+ return true;
69
+ });
70
+ return total;
71
+ };
72
+ //#endregion
73
+ //#region ../ui/src/components/edyn/rich-text-editor/extensions.ts
74
+ var MENTION_CHIP_CLASS = "rounded-sm bg-primary/10 px-1 py-0.5 font-medium text-primary";
75
+ var limitKey = new PluginKey("richTextMaxLength");
76
+ /** Refuses an edit that would take the text over the limit (a paste included) and lets one that shortens it through. */
77
+ var maxLengthExtension = (host) => Extension.create({
78
+ name: "maxLength",
79
+ addProseMirrorPlugins() {
80
+ return [new Plugin({
81
+ key: limitKey,
82
+ filterTransaction: (tr, state) => {
83
+ const limit = host.maxLength();
84
+ if (!tr.docChanged || limit === void 0) return true;
85
+ const next = countCharacters(tr.doc, host.toText());
86
+ return next <= limit || next <= countCharacters(state.doc, host.toText());
87
+ }
88
+ })];
89
+ }
90
+ });
91
+ /**
92
+ * The editor's whole schema: paragraph, line break, bold, italic, underline, bullet and numbered lists, link, and the
93
+ * mention chip. Whatever else arrives (a paste, `value`) is not in the schema, so ProseMirror drops the markup and keeps
94
+ * the text.
95
+ */
96
+ var buildExtensions = (host) => [
97
+ Document,
98
+ Paragraph,
99
+ Text,
100
+ HardBreak,
101
+ Bold,
102
+ Italic,
103
+ Underline,
104
+ BulletList,
105
+ OrderedList.extend({ addAttributes() {
106
+ return {};
107
+ } }),
108
+ ListItem,
109
+ HorizontalRule,
110
+ Link.extend({ addAttributes() {
111
+ return { href: {
112
+ default: null,
113
+ parseHTML: (element) => element.getAttribute("href"),
114
+ renderHTML: (attributes) => attributes.href ? { href: attributes.href } : {}
115
+ } };
116
+ } }).configure({
117
+ openOnClick: false,
118
+ autolink: true,
119
+ linkOnPaste: true,
120
+ defaultProtocol: "https",
121
+ protocols: ["mailto"],
122
+ isAllowedUri: (url) => isAllowedRichTextHref(url),
123
+ HTMLAttributes: {
124
+ rel: null,
125
+ target: null,
126
+ class: null
127
+ }
128
+ }),
129
+ UndoRedo,
130
+ maxLengthExtension(host),
131
+ Mention.extend({ addAttributes() {
132
+ return {
133
+ id: {
134
+ default: null,
135
+ parseHTML: (element) => element.getAttribute("data-id"),
136
+ renderHTML: (attributes) => attributes.id ? { "data-id": attributes.id } : {}
137
+ },
138
+ label: {
139
+ default: null,
140
+ parseHTML: (element) => element.getAttribute("data-label"),
141
+ renderHTML: (attributes) => attributes.label ? { "data-label": attributes.label } : {}
142
+ }
143
+ };
144
+ } }).configure({
145
+ HTMLAttributes: { class: MENTION_CHIP_CLASS },
146
+ renderText: ({ node }) => `@${node.attrs.label ?? node.attrs.id}`,
147
+ renderHTML: ({ options, node }) => [
148
+ "span",
149
+ mergeAttributes(options.HTMLAttributes),
150
+ `@${node.attrs.label ?? node.attrs.id}`
151
+ ],
152
+ suggestion: {
153
+ char: "@",
154
+ allow: () => host.mentionEnabled(),
155
+ items: () => [],
156
+ command: ({ editor, range, props }) => {
157
+ editor.chain().focus().insertContentAt(range, [{
158
+ type: "mention",
159
+ attrs: {
160
+ id: props.id,
161
+ label: props.label
162
+ }
163
+ }, {
164
+ type: "text",
165
+ text: " "
166
+ }]).run();
167
+ },
168
+ render: () => ({
169
+ onStart: (props) => host.onSuggestion(props),
170
+ onUpdate: (props) => host.onSuggestion(props),
171
+ onKeyDown: (props) => host.onSuggestionKey(props),
172
+ onExit: () => host.onSuggestion(null)
173
+ })
174
+ }
175
+ })
176
+ ];
177
+ //#endregion
178
+ //#region ../ui/src/components/edyn/rich-text-editor/mention-list.tsx
179
+ var FIRST = {
180
+ status: "loading",
181
+ items: [],
182
+ page: 1,
183
+ hasMore: false,
184
+ more: false,
185
+ active: 0
186
+ };
187
+ var toPage = (result) => Array.isArray(result) ? {
188
+ items: result,
189
+ hasMore: false
190
+ } : result;
191
+ var optionId = (listId, index) => `${listId}-${index}`;
192
+ /** The list that opens under the caret when the writer types @. */
193
+ var MentionList = ({ query, rect, config, labels, listId, onPick, keyHandler, onActiveChange }) => {
194
+ const [state, setState] = useState(FIRST);
195
+ const refSearch = useRef(config.search);
196
+ refSearch.current = config.search;
197
+ const refFirst = useRef(true);
198
+ const refController = useRef(null);
199
+ const refViewport = useRef(null);
200
+ const debounce = config.debounce ?? 250;
201
+ useEffect(() => {
202
+ const controller = new AbortController();
203
+ refController.current = controller;
204
+ setState((previous) => ({
205
+ ...previous,
206
+ status: "loading",
207
+ page: 1,
208
+ more: false
209
+ }));
210
+ const timer = setTimeout(async () => {
211
+ try {
212
+ const page = toPage(await refSearch.current(query, {
213
+ page: 1,
214
+ signal: controller.signal
215
+ }));
216
+ if (controller.signal.aborted) return;
217
+ setState({
218
+ status: "ready",
219
+ items: page.items,
220
+ page: 1,
221
+ hasMore: page.hasMore === true,
222
+ more: false,
223
+ active: 0
224
+ });
225
+ } catch {
226
+ if (!controller.signal.aborted) setState((previous) => ({
227
+ ...previous,
228
+ status: "error",
229
+ items: [],
230
+ hasMore: false
231
+ }));
232
+ }
233
+ }, refFirst.current ? 0 : debounce);
234
+ refFirst.current = false;
235
+ return () => {
236
+ clearTimeout(timer);
237
+ controller.abort();
238
+ };
239
+ }, [query, debounce]);
240
+ const loadMore = () => {
241
+ const controller = refController.current;
242
+ if (!controller || state.status !== "ready" || !state.hasMore || state.more) return;
243
+ const next = state.page + 1;
244
+ setState((previous) => ({
245
+ ...previous,
246
+ more: true
247
+ }));
248
+ refSearch.current(query, {
249
+ page: next,
250
+ signal: controller.signal
251
+ }).then((result) => {
252
+ if (controller.signal.aborted) return;
253
+ const page = toPage(result);
254
+ setState((previous) => ({
255
+ ...previous,
256
+ items: [...previous.items, ...page.items.filter((item) => !previous.items.some((known) => known.id === item.id))],
257
+ page: next,
258
+ hasMore: page.hasMore === true,
259
+ more: false
260
+ }));
261
+ }).catch(() => {
262
+ if (!controller.signal.aborted) setState((previous) => ({
263
+ ...previous,
264
+ more: false,
265
+ hasMore: false
266
+ }));
267
+ });
268
+ };
269
+ const refLoadMore = useRef(loadMore);
270
+ refLoadMore.current = loadMore;
271
+ const activeItem = state.items[state.active];
272
+ useEffect(() => {
273
+ onActiveChange(activeItem ? optionId(listId, state.active) : void 0);
274
+ }, [
275
+ activeItem,
276
+ listId,
277
+ onActiveChange,
278
+ state.active
279
+ ]);
280
+ useEffect(() => {
281
+ document.getElementById(optionId(listId, state.active))?.scrollIntoView({ block: "nearest" });
282
+ }, [listId, state.active]);
283
+ const watchSentinel = useCallback((node) => {
284
+ if (!node) return;
285
+ const observer = new IntersectionObserver((entries) => {
286
+ if (entries.some((entry) => entry.isIntersecting)) refLoadMore.current();
287
+ }, { root: refViewport.current });
288
+ observer.observe(node);
289
+ return () => observer.disconnect();
290
+ }, []);
291
+ useEffect(() => {
292
+ keyHandler.current = (event) => {
293
+ const count = state.items.length;
294
+ if (event.key === "Escape") {
295
+ event.stopPropagation();
296
+ return false;
297
+ }
298
+ if (count === 0) return false;
299
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
300
+ const step = event.key === "ArrowDown" ? 1 : -1;
301
+ const next = (state.active + step + count) % count;
302
+ if (step === 1 && state.active === count - 1) refLoadMore.current();
303
+ setState((previous) => ({
304
+ ...previous,
305
+ active: next
306
+ }));
307
+ return true;
308
+ }
309
+ if ((event.key === "Enter" || event.key === "Tab") && activeItem) {
310
+ onPick(activeItem);
311
+ return true;
312
+ }
313
+ return false;
314
+ };
315
+ return () => {
316
+ keyHandler.current = null;
317
+ };
318
+ }, [
319
+ activeItem,
320
+ keyHandler,
321
+ onPick,
322
+ state.active,
323
+ state.items.length
324
+ ]);
325
+ const anchor = { getBoundingClientRect: () => rect?.() ?? new DOMRect(0, 0, 0, 0) };
326
+ const announcement = state.status === "loading" ? labels.mentionSearching : state.status === "error" ? labels.mentionError : state.items.length === 0 ? labels.mentionEmpty : labels.mentionResults.replace("{count}", String(state.items.length));
327
+ return /* @__PURE__ */ jsx(Popover, {
328
+ open: true,
329
+ onOpenChange: () => void 0,
330
+ children: /* @__PURE__ */ jsxs(PopoverContent, {
331
+ anchor,
332
+ align: "start",
333
+ side: "bottom",
334
+ sideOffset: 6,
335
+ initialFocus: false,
336
+ finalFocus: false,
337
+ "data-slot": "rich-text-mention-popup",
338
+ className: "w-72 gap-0 p-1",
339
+ children: [/* @__PURE__ */ jsxs(ScrollArea, {
340
+ className: "max-h-60",
341
+ viewportRef: refViewport,
342
+ children: [
343
+ /* @__PURE__ */ jsx("div", {
344
+ role: "listbox",
345
+ id: listId,
346
+ "aria-label": labels.mentionList,
347
+ className: cn("flex flex-col gap-px", state.items.length === 0 && "hidden"),
348
+ children: state.items.map((item, index) => /* @__PURE__ */ jsxs("div", {
349
+ id: optionId(listId, index),
350
+ role: "option",
351
+ "aria-selected": index === state.active,
352
+ "data-slot": "rich-text-mention-option",
353
+ "data-highlighted": index === state.active ? "" : void 0,
354
+ className: cn(popupItemClass("base-ui"), "h-auto min-h-8 flex-col items-start gap-0"),
355
+ onMouseDown: (event) => event.preventDefault(),
356
+ onMouseMove: () => setState((previous) => previous.active === index ? previous : {
357
+ ...previous,
358
+ active: index
359
+ }),
360
+ onClick: () => onPick(item),
361
+ children: [/* @__PURE__ */ jsx("span", {
362
+ className: "truncate",
363
+ children: item.label
364
+ }), item.secondary ? /* @__PURE__ */ jsx("span", {
365
+ className: "truncate text-xs text-foreground/70",
366
+ children: item.secondary
367
+ }) : null]
368
+ }, item.id))
369
+ }),
370
+ state.status === "loading" && state.items.length === 0 ? /* @__PURE__ */ jsxs("div", {
371
+ className: "flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground",
372
+ children: [/* @__PURE__ */ jsx(IconLoader2, {
373
+ className: "size-4 animate-spin motion-reduce:animate-none",
374
+ "aria-hidden": true
375
+ }), labels.mentionSearching]
376
+ }) : null,
377
+ state.status === "ready" && state.items.length === 0 ? /* @__PURE__ */ jsx("div", {
378
+ className: "px-2 py-1.5 text-sm text-muted-foreground",
379
+ children: labels.mentionEmpty
380
+ }) : null,
381
+ state.status === "error" ? /* @__PURE__ */ jsx("div", {
382
+ className: "px-2 py-1.5 text-sm text-destructive",
383
+ children: labels.mentionError
384
+ }) : null,
385
+ state.hasMore ? /* @__PURE__ */ jsx("div", {
386
+ ref: watchSentinel,
387
+ className: "flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground",
388
+ children: state.more ? labels.mentionLoadingMore : null
389
+ }, state.items.length) : null
390
+ ]
391
+ }), /* @__PURE__ */ jsx("div", {
392
+ role: "status",
393
+ className: "sr-only",
394
+ children: announcement
395
+ })]
396
+ })
397
+ });
398
+ };
399
+ //#endregion
400
+ //#region ../ui/src/components/edyn/rich-text-editor/toolbar.tsx
401
+ var BUTTON_CLASS = cn(toggleVariants({
402
+ variant: "default",
403
+ size: "sm"
404
+ }), "size-7 px-0");
405
+ /** A typed address becomes a link: "name@example.test" a mailto, "example.test" an https address. */
406
+ var normalizeHref = (raw) => {
407
+ const value = raw.trim();
408
+ if (value === "" || /^[a-z][a-z0-9+.-]*:/i.test(value)) return value;
409
+ return /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(value) ? `mailto:${value}` : `https://${value}`;
410
+ };
411
+ var ToolbarButton = ({ label, pressed, disabled, onClick, children }) => /* @__PURE__ */ jsx(Toolbar.Button, {
412
+ type: "button",
413
+ "aria-label": label,
414
+ title: label,
415
+ "aria-pressed": pressed,
416
+ "data-pressed": pressed ? "" : void 0,
417
+ disabled,
418
+ className: BUTTON_CLASS,
419
+ onMouseDown: (event) => event.preventDefault(),
420
+ onClick,
421
+ children
422
+ });
423
+ var hrefAt = (editor, range) => {
424
+ const { doc } = editor.state;
425
+ const marks = range.from < range.to ? doc.nodeAt(range.from)?.marks ?? [] : doc.resolve(range.from).marks();
426
+ return String(marks.find((mark) => mark.type.name === "link")?.attrs.href ?? "");
427
+ };
428
+ var LinkControl = ({ range, editor, labels, active, disabled, open, onOpenChange }) => {
429
+ const [address, setAddress] = useState("");
430
+ const [invalid, setInvalid] = useState(false);
431
+ useEffect(() => {
432
+ if (open) {
433
+ setAddress(range.current ? hrefAt(editor, range.current) : "");
434
+ setInvalid(false);
435
+ }
436
+ }, [
437
+ editor,
438
+ open,
439
+ range
440
+ ]);
441
+ const apply = (event) => {
442
+ event.preventDefault();
443
+ event.stopPropagation();
444
+ const href = normalizeHref(address);
445
+ if (!isAllowedRichTextHref(href)) {
446
+ setInvalid(true);
447
+ return;
448
+ }
449
+ const saved = range.current ?? {
450
+ from: editor.state.selection.from,
451
+ to: editor.state.selection.to
452
+ };
453
+ const chain = editor.chain().focus().setTextSelection(saved);
454
+ if (saved.from === saved.to && !hrefAt(editor, saved)) chain.insertContent({
455
+ type: "text",
456
+ text: href,
457
+ marks: [{
458
+ type: "link",
459
+ attrs: { href }
460
+ }]
461
+ }).run();
462
+ else chain.extendMarkRange("link").setLink({ href }).run();
463
+ onOpenChange(false);
464
+ };
465
+ const remove = () => {
466
+ const saved = range.current ?? {
467
+ from: editor.state.selection.from,
468
+ to: editor.state.selection.to
469
+ };
470
+ editor.chain().focus().setTextSelection(saved).extendMarkRange("link").unsetLink().run();
471
+ onOpenChange(false);
472
+ };
473
+ return /* @__PURE__ */ jsxs(Popover, {
474
+ open,
475
+ onOpenChange,
476
+ children: [/* @__PURE__ */ jsx(PopoverTrigger, {
477
+ render: /* @__PURE__ */ jsx(Toolbar.Button, {
478
+ type: "button",
479
+ "aria-label": labels.link,
480
+ title: labels.link,
481
+ "aria-pressed": active,
482
+ "data-pressed": active ? "" : void 0,
483
+ disabled,
484
+ className: BUTTON_CLASS
485
+ }),
486
+ children: /* @__PURE__ */ jsx(IconLink, { "aria-hidden": true })
487
+ }), /* @__PURE__ */ jsx(PopoverContent, {
488
+ align: "start",
489
+ className: "w-80",
490
+ children: /* @__PURE__ */ jsxs("form", {
491
+ className: "flex flex-col gap-2",
492
+ onSubmit: apply,
493
+ children: [
494
+ /* @__PURE__ */ jsx(Input, {
495
+ type: "text",
496
+ inputMode: "url",
497
+ "aria-label": labels.linkAddress,
498
+ "aria-invalid": invalid || void 0,
499
+ value: address,
500
+ onChange: (event) => {
501
+ setAddress(event.target.value);
502
+ setInvalid(false);
503
+ }
504
+ }),
505
+ invalid ? /* @__PURE__ */ jsx("p", {
506
+ className: "text-xs text-destructive",
507
+ children: labels.linkInvalid
508
+ }) : null,
509
+ /* @__PURE__ */ jsxs("div", {
510
+ className: "flex justify-end gap-2",
511
+ children: [active ? /* @__PURE__ */ jsx(Button, {
512
+ type: "button",
513
+ variant: "outline",
514
+ size: "sm",
515
+ onClick: remove,
516
+ children: labels.linkRemove
517
+ }) : null, /* @__PURE__ */ jsx(Button, {
518
+ type: "submit",
519
+ size: "sm",
520
+ children: labels.linkApply
521
+ })]
522
+ })
523
+ ]
524
+ })
525
+ })]
526
+ });
527
+ };
528
+ var RichTextToolbar = ({ linkRange, editor, labels, fields, disabled, linkOpen, onLinkOpenChange }) => {
529
+ const state = useEditorState({
530
+ editor,
531
+ selector: ({ editor: current }) => ({
532
+ bold: current.isActive("bold"),
533
+ italic: current.isActive("italic"),
534
+ underline: current.isActive("underline"),
535
+ bullets: current.isActive("bulletList"),
536
+ numbers: current.isActive("orderedList"),
537
+ link: current.isActive("link")
538
+ })
539
+ });
540
+ return /* @__PURE__ */ jsxs(Toolbar.Root, {
541
+ "data-slot": "rich-text-toolbar",
542
+ "aria-label": labels.toolbar,
543
+ className: "flex flex-wrap items-center gap-0.5 border-b border-input p-1",
544
+ children: [
545
+ /* @__PURE__ */ jsx(ToolbarButton, {
546
+ label: labels.bold,
547
+ pressed: state.bold,
548
+ disabled,
549
+ onClick: () => editor.chain().focus().toggleBold().run(),
550
+ children: /* @__PURE__ */ jsx(IconBold, { "aria-hidden": true })
551
+ }),
552
+ /* @__PURE__ */ jsx(ToolbarButton, {
553
+ label: labels.italic,
554
+ pressed: state.italic,
555
+ disabled,
556
+ onClick: () => editor.chain().focus().toggleItalic().run(),
557
+ children: /* @__PURE__ */ jsx(IconItalic, { "aria-hidden": true })
558
+ }),
559
+ /* @__PURE__ */ jsx(ToolbarButton, {
560
+ label: labels.underline,
561
+ pressed: state.underline,
562
+ disabled,
563
+ onClick: () => editor.chain().focus().toggleUnderline().run(),
564
+ children: /* @__PURE__ */ jsx(IconUnderline, { "aria-hidden": true })
565
+ }),
566
+ /* @__PURE__ */ jsx(Toolbar.Separator, { className: "mx-1 h-4 w-px bg-border" }),
567
+ /* @__PURE__ */ jsx(ToolbarButton, {
568
+ label: labels.bulletList,
569
+ pressed: state.bullets,
570
+ disabled,
571
+ onClick: () => editor.chain().focus().toggleBulletList().run(),
572
+ children: /* @__PURE__ */ jsx(IconList, { "aria-hidden": true })
573
+ }),
574
+ /* @__PURE__ */ jsx(ToolbarButton, {
575
+ label: labels.numberedList,
576
+ pressed: state.numbers,
577
+ disabled,
578
+ onClick: () => editor.chain().focus().toggleOrderedList().run(),
579
+ children: /* @__PURE__ */ jsx(IconListNumbers, { "aria-hidden": true })
580
+ }),
581
+ /* @__PURE__ */ jsx(LinkControl, {
582
+ range: linkRange,
583
+ editor,
584
+ labels,
585
+ active: state.link,
586
+ disabled,
587
+ open: linkOpen,
588
+ onOpenChange: onLinkOpenChange
589
+ }),
590
+ /* @__PURE__ */ jsx(ToolbarButton, {
591
+ label: labels.horizontalRule,
592
+ disabled,
593
+ onClick: () => editor.chain().focus().setHorizontalRule().run(),
594
+ children: /* @__PURE__ */ jsx(IconSeparatorHorizontal, { "aria-hidden": true })
595
+ }),
596
+ fields && fields.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Toolbar.Separator, { className: "mx-1 h-4 w-px bg-border" }), /* @__PURE__ */ jsxs(DropdownMenu, { children: [/* @__PURE__ */ jsx(DropdownMenuTrigger, {
597
+ render: /* @__PURE__ */ jsx(Toolbar.Button, {
598
+ type: "button",
599
+ "aria-label": labels.insertField,
600
+ title: labels.insertField,
601
+ disabled,
602
+ className: BUTTON_CLASS
603
+ }),
604
+ children: /* @__PURE__ */ jsx(IconVariable, { "aria-hidden": true })
605
+ }), /* @__PURE__ */ jsx(DropdownMenuContent, {
606
+ align: "start",
607
+ className: "w-56",
608
+ children: fields.map((field) => /* @__PURE__ */ jsx(DropdownMenuItem, {
609
+ onClick: () => editor.chain().focus().insertContent({
610
+ type: "text",
611
+ text: `{{${field.id}}}`
612
+ }).run(),
613
+ children: field.label
614
+ }, field.id))
615
+ })] })] }) : null
616
+ ]
617
+ });
618
+ };
619
+ //#endregion
620
+ //#region ../ui/src/components/edyn/rich-text-editor/types.ts
621
+ var DEFAULT_RICH_TEXT_LABELS = {
622
+ toolbar: "Formatting",
623
+ bold: "Bold",
624
+ italic: "Italic",
625
+ underline: "Underline",
626
+ bulletList: "Bulleted list",
627
+ numberedList: "Numbered list",
628
+ link: "Link",
629
+ horizontalRule: "Horizontal line",
630
+ insertField: "Insert a field",
631
+ linkAddress: "Link address",
632
+ linkApply: "Apply",
633
+ linkRemove: "Remove link",
634
+ linkInvalid: "Enter an http, https or mailto address.",
635
+ mentionList: "Mentions",
636
+ mentionSearching: "Searching...",
637
+ mentionEmpty: "No matches",
638
+ mentionError: "The search failed",
639
+ mentionLoadingMore: "Loading more...",
640
+ mentionResults: "{count} results"
641
+ };
642
+ //#endregion
643
+ //#region ../ui/src/components/edyn/rich-text-editor/rich-text-editor.tsx
644
+ var LINE = 1.5;
645
+ /**
646
+ * A small rich-text field: paragraphs, bold, italic, underline, bulleted and numbered lists and links, with optional
647
+ * @mentions the app answers asynchronously. The value is HTML in and out, and only the elements in `RICH_TEXT_ELEMENTS`
648
+ * can come out (no class, no style), whatever is pasted. A mention is a chip while editing and plain text in the HTML.
649
+ * The server should still sanitise: this is a convenience for the writer, not a security boundary.
650
+ */
651
+ var RichTextEditor = ({ value, defaultValue = "", onChange, placeholder, disabled = false, readOnly = false, invalid = false, minRows = 6, maxRows = 20, maxLength, mention, fields, toolbar = true, labels, id, autoFocus = false, onPasteFiles, ref, className, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...props }) => {
652
+ const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";
653
+ const text = useMemo(() => ({
654
+ ...DEFAULT_RICH_TEXT_LABELS,
655
+ ...labels
656
+ }), [labels]);
657
+ const listId = useId();
658
+ const [suggestion, setSuggestion] = useState(null);
659
+ const [activeOption, setActiveOption] = useState(void 0);
660
+ const [linkOpen, setLinkOpen] = useState(false);
661
+ const refLinkRange = useRef(null);
662
+ const refKeys = useRef(null);
663
+ const refEmitted = useRef(void 0);
664
+ const editable = !disabled && !readOnly;
665
+ const showToolbar = toolbar && !readOnly;
666
+ const latest = useRef({
667
+ mention,
668
+ maxLength,
669
+ onChange,
670
+ onPasteFiles,
671
+ toolbar: showToolbar
672
+ });
673
+ latest.current = {
674
+ mention,
675
+ maxLength,
676
+ onChange,
677
+ onPasteFiles,
678
+ toolbar: showToolbar
679
+ };
680
+ const host = useMemo(() => ({
681
+ mentionEnabled: () => latest.current.mention !== void 0,
682
+ maxLength: () => latest.current.maxLength,
683
+ toText: () => latest.current.mention?.toText ?? defaultMentionToText,
684
+ onSuggestion: (props) => {
685
+ if (props === null) setActiveOption(void 0);
686
+ setSuggestion(props === null ? null : {
687
+ query: props.query,
688
+ rect: props.clientRect ?? null,
689
+ command: props.command
690
+ });
691
+ },
692
+ onSuggestionKey: ({ event }) => refKeys.current?.(event) ?? false
693
+ }), []);
694
+ const extensions = useMemo(() => buildExtensions(host), [host]);
695
+ const emit = useCallback((current) => {
696
+ const html = isDocumentEmpty(current.state.doc) ? "" : serializeDocument(current.state.doc, host.toText());
697
+ refEmitted.current = html;
698
+ return html;
699
+ }, [host]);
700
+ const attributes = useMemo(() => {
701
+ const entries = {
702
+ role: "textbox",
703
+ "aria-multiline": "true",
704
+ "aria-autocomplete": mention ? "list" : void 0,
705
+ "aria-controls": suggestion ? listId : void 0,
706
+ "aria-activedescendant": suggestion ? activeOption : void 0,
707
+ "aria-label": ariaLabel,
708
+ "aria-labelledby": ariaLabelledBy,
709
+ "aria-describedby": ariaDescribedBy,
710
+ "aria-invalid": isInvalid ? "true" : void 0,
711
+ "aria-readonly": readOnly ? "true" : void 0,
712
+ "aria-disabled": disabled ? "true" : void 0,
713
+ "aria-placeholder": placeholder,
714
+ tabindex: readOnly && !disabled ? "0" : void 0,
715
+ id,
716
+ "data-slot": "rich-text-editor-content",
717
+ class: cn(RICH_TEXT_BODY_CLASS, "min-h-(--rich-text-min) px-2.5 py-2 outline-none")
718
+ };
719
+ return Object.fromEntries(Object.entries(entries).filter((entry) => entry[1] !== void 0));
720
+ }, [
721
+ activeOption,
722
+ ariaDescribedBy,
723
+ ariaLabel,
724
+ ariaLabelledBy,
725
+ disabled,
726
+ id,
727
+ isInvalid,
728
+ listId,
729
+ mention,
730
+ placeholder,
731
+ readOnly,
732
+ suggestion
733
+ ]);
734
+ const editor = useEditor({
735
+ extensions,
736
+ content: value ?? defaultValue,
737
+ editable,
738
+ autofocus: autoFocus ? "end" : false,
739
+ editorProps: {
740
+ attributes,
741
+ handlePaste: (_view, event) => {
742
+ const data = event.clipboardData;
743
+ if (!(data !== null && data.files.length > 0 && data.getData("text/plain") === "" && data.getData("text/html") === "") || !latest.current.onPasteFiles) return false;
744
+ latest.current.onPasteFiles(Array.from(data.files));
745
+ return true;
746
+ },
747
+ handleKeyDown: (view, event) => {
748
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "k" && latest.current.toolbar) {
749
+ event.preventDefault();
750
+ refLinkRange.current = {
751
+ from: view.state.selection.from,
752
+ to: view.state.selection.to
753
+ };
754
+ setLinkOpen(true);
755
+ return true;
756
+ }
757
+ return false;
758
+ }
759
+ },
760
+ onBlur: () => {
761
+ setSuggestion(null);
762
+ setActiveOption(void 0);
763
+ },
764
+ onUpdate: ({ editor: current }) => {
765
+ latest.current.onChange?.(emit(current));
766
+ }
767
+ });
768
+ useImperativeHandle(ref, () => ({
769
+ insertText: (value) => {
770
+ editor.chain().focus().insertContent({
771
+ type: "text",
772
+ text: value
773
+ }).run();
774
+ },
775
+ focus: () => {
776
+ editor.commands.focus();
777
+ }
778
+ }), [editor]);
779
+ useEffect(() => {
780
+ editor.setEditable(editable);
781
+ }, [editable, editor]);
782
+ useEffect(() => {
783
+ if (value === void 0 || value === refEmitted.current) return;
784
+ refEmitted.current = value;
785
+ editor.commands.setContent(value, { emitUpdate: false });
786
+ }, [editor, value]);
787
+ const empty = useEditorState({
788
+ editor,
789
+ selector: ({ editor: current }) => isDocumentEmpty(current.state.doc)
790
+ });
791
+ const pick = useCallback((item) => {
792
+ suggestion?.command(item);
793
+ }, [suggestion]);
794
+ return /* @__PURE__ */ jsxs("div", {
795
+ "data-slot": "rich-text-editor",
796
+ "data-invalid": isInvalid ? "" : void 0,
797
+ "data-disabled": disabled ? "" : void 0,
798
+ "data-readonly": readOnly ? "" : void 0,
799
+ className: cn("flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent text-base transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:bg-input/50 data-disabled:opacity-50 data-invalid:border-destructive data-invalid:ring-3 data-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:data-disabled:bg-input/80 dark:data-invalid:ring-destructive/40", className),
800
+ ...props,
801
+ children: [
802
+ showToolbar ? /* @__PURE__ */ jsx(RichTextToolbar, {
803
+ editor,
804
+ labels: text,
805
+ fields,
806
+ disabled,
807
+ linkOpen,
808
+ linkRange: refLinkRange,
809
+ onLinkOpenChange: (next) => {
810
+ if (next && !linkOpen) refLinkRange.current = {
811
+ from: editor.state.selection.from,
812
+ to: editor.state.selection.to
813
+ };
814
+ setLinkOpen(next);
815
+ }
816
+ }) : null,
817
+ /* @__PURE__ */ jsxs(ScrollArea, {
818
+ className: "relative",
819
+ style: {
820
+ maxHeight: `${maxRows * LINE + 1}rem`,
821
+ ["--rich-text-min"]: `${minRows * LINE + 1}rem`
822
+ },
823
+ focusable: false,
824
+ children: [empty && placeholder ? /* @__PURE__ */ jsx("p", {
825
+ "aria-hidden": true,
826
+ className: "pointer-events-none absolute start-2.5 top-2 text-muted-foreground",
827
+ children: placeholder
828
+ }) : null, /* @__PURE__ */ jsx(EditorContent, { editor })]
829
+ }),
830
+ suggestion && mention ? /* @__PURE__ */ jsx(MentionList, {
831
+ query: suggestion.query,
832
+ rect: suggestion.rect,
833
+ config: mention,
834
+ labels: text,
835
+ listId,
836
+ onPick: pick,
837
+ keyHandler: refKeys,
838
+ onActiveChange: setActiveOption
839
+ }) : null
840
+ ]
841
+ });
842
+ };
843
+ //#endregion
844
+ export { DEFAULT_RICH_TEXT_LABELS, RICH_TEXT_ATTRIBUTES, RICH_TEXT_ELEMENTS, RICH_TEXT_LINK_PROTOCOLS, RichTextEditor };
845
+
846
+ //# sourceMappingURL=rich-text-editor.js.map