@exsto/ds 0.0.1-rc.6 → 0.0.1-rc.8

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 (26) 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 +3 -470
  6. package/dist/index.js.map +1 -1
  7. package/dist/rich-text-editor.js +904 -0
  8. package/dist/rich-text-editor.js.map +1 -0
  9. package/dist/styles.css +159 -0
  10. package/dist/ui/src/components/edyn/rich-text-content/allow-list.d.ts +11 -0
  11. package/dist/ui/src/components/edyn/rich-text-content/index.d.ts +2 -0
  12. package/dist/ui/src/components/edyn/rich-text-content/rich-text-content.d.ts +22 -0
  13. package/dist/ui/src/components/edyn/rich-text-content/rich-text-content.test.d.ts +8 -0
  14. package/dist/ui/src/components/edyn/rich-text-editor/extensions.d.ts +18 -0
  15. package/dist/ui/src/components/edyn/rich-text-editor/index.d.ts +3 -0
  16. package/dist/ui/src/components/edyn/rich-text-editor/mention-list.d.ts +17 -0
  17. package/dist/ui/src/components/edyn/rich-text-editor/rich-text-editor.d.ts +53 -0
  18. package/dist/ui/src/components/edyn/rich-text-editor/rich-text-editor.test.d.ts +6 -0
  19. package/dist/ui/src/components/edyn/rich-text-editor/serialize.d.ts +13 -0
  20. package/dist/ui/src/components/edyn/rich-text-editor/sync.d.ts +19 -0
  21. package/dist/ui/src/components/edyn/rich-text-editor/sync.test.d.ts +11 -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/index.d.ts +1 -0
  26. package/package.json +21 -1
@@ -0,0 +1,904 @@
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, useReducer, 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, TextSelection } 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/sync.ts
401
+ /**
402
+ * Whether commands may be called on the editor. TipTap builds an editor during the render, mounts its view later, and
403
+ * destroys one that has not been mounted within a moment (useEditor then makes a new one), so an effect can be handed an
404
+ * editor that is not there yet or no longer is. `isDestroyed` is true for both: it reads true until the view exists. A
405
+ * destroyed editor has no command manager, so `editor.commands` throws on it.
406
+ */
407
+ var isLive = (editor) => editor.isInitialized && !editor.isDestroyed;
408
+ /**
409
+ * Brings the editor to a value the parent passed in, if it is one the editor did not just report itself.
410
+ * `known` is the last HTML the editor and the parent agree on (the starting value, then whatever the editor emitted or was
411
+ * given). The caret stays where it was, kept inside the new content, so a value changed from outside while the writer is
412
+ * typing does not throw the caret to the start.
413
+ */
414
+ var syncValue = (editor, value, known) => {
415
+ if (!isLive(editor) || value === void 0 || value === known.current) return;
416
+ known.current = value;
417
+ const { from, to } = editor.state.selection;
418
+ editor.commands.setContent(value, { emitUpdate: false });
419
+ editor.commands.command(({ tr }) => {
420
+ const size = tr.doc.content.size;
421
+ tr.setSelection(TextSelection.between(tr.doc.resolve(Math.min(from, size)), tr.doc.resolve(Math.min(to, size))));
422
+ return true;
423
+ });
424
+ };
425
+ /** Sets whether the editor can be edited without telling the app something changed (TipTap emits an update by default). */
426
+ var syncEditable = (editor, editable) => {
427
+ if (isLive(editor) && editor.isEditable !== editable) editor.setEditable(editable, false);
428
+ };
429
+ //#endregion
430
+ //#region ../ui/src/components/edyn/rich-text-editor/toolbar.tsx
431
+ var BUTTON_CLASS = cn(toggleVariants({
432
+ variant: "default",
433
+ size: "sm"
434
+ }), "size-7 px-0");
435
+ /** A typed address becomes a link: "name@example.test" a mailto, "example.test" an https address. */
436
+ var normalizeHref = (raw) => {
437
+ const value = raw.trim();
438
+ if (value === "" || /^[a-z][a-z0-9+.-]*:/i.test(value)) return value;
439
+ return /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(value) ? `mailto:${value}` : `https://${value}`;
440
+ };
441
+ var ToolbarButton = ({ label, pressed, disabled, onClick, children }) => /* @__PURE__ */ jsx(Toolbar.Button, {
442
+ type: "button",
443
+ "aria-label": label,
444
+ title: label,
445
+ "aria-pressed": pressed,
446
+ "data-pressed": pressed ? "" : void 0,
447
+ disabled,
448
+ className: BUTTON_CLASS,
449
+ onMouseDown: (event) => event.preventDefault(),
450
+ onClick,
451
+ children
452
+ });
453
+ var hrefAt = (editor, range) => {
454
+ const { doc } = editor.state;
455
+ const marks = range.from < range.to ? doc.nodeAt(range.from)?.marks ?? [] : doc.resolve(range.from).marks();
456
+ return String(marks.find((mark) => mark.type.name === "link")?.attrs.href ?? "");
457
+ };
458
+ var LinkControl = ({ range, editor, labels, active, disabled, open, onOpenChange }) => {
459
+ const [address, setAddress] = useState("");
460
+ const [invalid, setInvalid] = useState(false);
461
+ useEffect(() => {
462
+ if (open) {
463
+ setAddress(range.current ? hrefAt(editor, range.current) : "");
464
+ setInvalid(false);
465
+ }
466
+ }, [
467
+ editor,
468
+ open,
469
+ range
470
+ ]);
471
+ const apply = (event) => {
472
+ event.preventDefault();
473
+ event.stopPropagation();
474
+ const href = normalizeHref(address);
475
+ if (!isAllowedRichTextHref(href)) {
476
+ setInvalid(true);
477
+ return;
478
+ }
479
+ const saved = range.current ?? {
480
+ from: editor.state.selection.from,
481
+ to: editor.state.selection.to
482
+ };
483
+ const chain = editor.chain().focus().setTextSelection(saved);
484
+ if (saved.from === saved.to && !hrefAt(editor, saved)) chain.insertContent({
485
+ type: "text",
486
+ text: href,
487
+ marks: [{
488
+ type: "link",
489
+ attrs: { href }
490
+ }]
491
+ }).run();
492
+ else chain.extendMarkRange("link").setLink({ href }).run();
493
+ onOpenChange(false);
494
+ };
495
+ const remove = () => {
496
+ const saved = range.current ?? {
497
+ from: editor.state.selection.from,
498
+ to: editor.state.selection.to
499
+ };
500
+ editor.chain().focus().setTextSelection(saved).extendMarkRange("link").unsetLink().run();
501
+ onOpenChange(false);
502
+ };
503
+ return /* @__PURE__ */ jsxs(Popover, {
504
+ open,
505
+ onOpenChange,
506
+ children: [/* @__PURE__ */ jsx(PopoverTrigger, {
507
+ render: /* @__PURE__ */ jsx(Toolbar.Button, {
508
+ type: "button",
509
+ "aria-label": labels.link,
510
+ title: labels.link,
511
+ "aria-pressed": active,
512
+ "data-pressed": active ? "" : void 0,
513
+ disabled,
514
+ className: BUTTON_CLASS
515
+ }),
516
+ children: /* @__PURE__ */ jsx(IconLink, { "aria-hidden": true })
517
+ }), /* @__PURE__ */ jsx(PopoverContent, {
518
+ align: "start",
519
+ className: "w-80",
520
+ children: /* @__PURE__ */ jsxs("form", {
521
+ className: "flex flex-col gap-2",
522
+ onSubmit: apply,
523
+ children: [
524
+ /* @__PURE__ */ jsx(Input, {
525
+ type: "text",
526
+ inputMode: "url",
527
+ "aria-label": labels.linkAddress,
528
+ "aria-invalid": invalid || void 0,
529
+ value: address,
530
+ onChange: (event) => {
531
+ setAddress(event.target.value);
532
+ setInvalid(false);
533
+ }
534
+ }),
535
+ invalid ? /* @__PURE__ */ jsx("p", {
536
+ className: "text-xs text-destructive",
537
+ children: labels.linkInvalid
538
+ }) : null,
539
+ /* @__PURE__ */ jsxs("div", {
540
+ className: "flex justify-end gap-2",
541
+ children: [active ? /* @__PURE__ */ jsx(Button, {
542
+ type: "button",
543
+ variant: "outline",
544
+ size: "sm",
545
+ onClick: remove,
546
+ children: labels.linkRemove
547
+ }) : null, /* @__PURE__ */ jsx(Button, {
548
+ type: "submit",
549
+ size: "sm",
550
+ children: labels.linkApply
551
+ })]
552
+ })
553
+ ]
554
+ })
555
+ })]
556
+ });
557
+ };
558
+ var RichTextToolbar = ({ linkRange, editor, labels, fields, disabled, linkOpen, onLinkOpenChange }) => {
559
+ const state = useEditorState({
560
+ editor,
561
+ selector: ({ editor: current }) => ({
562
+ bold: current.isActive("bold"),
563
+ italic: current.isActive("italic"),
564
+ underline: current.isActive("underline"),
565
+ bullets: current.isActive("bulletList"),
566
+ numbers: current.isActive("orderedList"),
567
+ link: current.isActive("link")
568
+ })
569
+ });
570
+ return /* @__PURE__ */ jsxs(Toolbar.Root, {
571
+ "data-slot": "rich-text-toolbar",
572
+ "aria-label": labels.toolbar,
573
+ className: "flex flex-wrap items-center gap-0.5 border-b border-input p-1",
574
+ children: [
575
+ /* @__PURE__ */ jsx(ToolbarButton, {
576
+ label: labels.bold,
577
+ pressed: state.bold,
578
+ disabled,
579
+ onClick: () => editor.chain().focus().toggleBold().run(),
580
+ children: /* @__PURE__ */ jsx(IconBold, { "aria-hidden": true })
581
+ }),
582
+ /* @__PURE__ */ jsx(ToolbarButton, {
583
+ label: labels.italic,
584
+ pressed: state.italic,
585
+ disabled,
586
+ onClick: () => editor.chain().focus().toggleItalic().run(),
587
+ children: /* @__PURE__ */ jsx(IconItalic, { "aria-hidden": true })
588
+ }),
589
+ /* @__PURE__ */ jsx(ToolbarButton, {
590
+ label: labels.underline,
591
+ pressed: state.underline,
592
+ disabled,
593
+ onClick: () => editor.chain().focus().toggleUnderline().run(),
594
+ children: /* @__PURE__ */ jsx(IconUnderline, { "aria-hidden": true })
595
+ }),
596
+ /* @__PURE__ */ jsx(Toolbar.Separator, { className: "mx-1 h-4 w-px bg-border" }),
597
+ /* @__PURE__ */ jsx(ToolbarButton, {
598
+ label: labels.bulletList,
599
+ pressed: state.bullets,
600
+ disabled,
601
+ onClick: () => editor.chain().focus().toggleBulletList().run(),
602
+ children: /* @__PURE__ */ jsx(IconList, { "aria-hidden": true })
603
+ }),
604
+ /* @__PURE__ */ jsx(ToolbarButton, {
605
+ label: labels.numberedList,
606
+ pressed: state.numbers,
607
+ disabled,
608
+ onClick: () => editor.chain().focus().toggleOrderedList().run(),
609
+ children: /* @__PURE__ */ jsx(IconListNumbers, { "aria-hidden": true })
610
+ }),
611
+ /* @__PURE__ */ jsx(LinkControl, {
612
+ range: linkRange,
613
+ editor,
614
+ labels,
615
+ active: state.link,
616
+ disabled,
617
+ open: linkOpen,
618
+ onOpenChange: onLinkOpenChange
619
+ }),
620
+ /* @__PURE__ */ jsx(ToolbarButton, {
621
+ label: labels.horizontalRule,
622
+ disabled,
623
+ onClick: () => editor.chain().focus().setHorizontalRule().run(),
624
+ children: /* @__PURE__ */ jsx(IconSeparatorHorizontal, { "aria-hidden": true })
625
+ }),
626
+ 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, {
627
+ render: /* @__PURE__ */ jsx(Toolbar.Button, {
628
+ type: "button",
629
+ "aria-label": labels.insertField,
630
+ title: labels.insertField,
631
+ disabled,
632
+ className: BUTTON_CLASS
633
+ }),
634
+ children: /* @__PURE__ */ jsx(IconVariable, { "aria-hidden": true })
635
+ }), /* @__PURE__ */ jsx(DropdownMenuContent, {
636
+ align: "start",
637
+ className: "w-56",
638
+ children: fields.map((field) => /* @__PURE__ */ jsx(DropdownMenuItem, {
639
+ onClick: () => editor.chain().focus().insertContent({
640
+ type: "text",
641
+ text: `{{${field.id}}}`
642
+ }).run(),
643
+ children: field.label
644
+ }, field.id))
645
+ })] })] }) : null
646
+ ]
647
+ });
648
+ };
649
+ //#endregion
650
+ //#region ../ui/src/components/edyn/rich-text-editor/types.ts
651
+ var DEFAULT_RICH_TEXT_LABELS = {
652
+ toolbar: "Formatting",
653
+ bold: "Bold",
654
+ italic: "Italic",
655
+ underline: "Underline",
656
+ bulletList: "Bulleted list",
657
+ numberedList: "Numbered list",
658
+ link: "Link",
659
+ horizontalRule: "Horizontal line",
660
+ insertField: "Insert a field",
661
+ linkAddress: "Link address",
662
+ linkApply: "Apply",
663
+ linkRemove: "Remove link",
664
+ linkInvalid: "Enter an http, https or mailto address.",
665
+ mentionList: "Mentions",
666
+ mentionSearching: "Searching...",
667
+ mentionEmpty: "No matches",
668
+ mentionError: "The search failed",
669
+ mentionLoadingMore: "Loading more...",
670
+ mentionResults: "{count} results"
671
+ };
672
+ //#endregion
673
+ //#region ../ui/src/components/edyn/rich-text-editor/rich-text-editor.tsx
674
+ var LINE = 1.5;
675
+ /**
676
+ * A small rich-text field: paragraphs, bold, italic, underline, bulleted and numbered lists and links, with optional
677
+ * @mentions the app answers asynchronously. The value is HTML in and out, and only the elements in `RICH_TEXT_ELEMENTS`
678
+ * can come out (no class, no style), whatever is pasted. A mention is a chip while editing and plain text in the HTML.
679
+ * The server should still sanitise: this is a convenience for the writer, not a security boundary.
680
+ */
681
+ 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 }) => {
682
+ const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";
683
+ const text = useMemo(() => ({
684
+ ...DEFAULT_RICH_TEXT_LABELS,
685
+ ...labels
686
+ }), [labels]);
687
+ const listId = useId();
688
+ const [suggestion, setSuggestion] = useState(null);
689
+ const [activeOption, setActiveOption] = useState(void 0);
690
+ const [linkOpen, setLinkOpen] = useState(false);
691
+ const refLinkRange = useRef(null);
692
+ const refKeys = useRef(null);
693
+ const refKnown = useRef(value ?? defaultValue);
694
+ const refPending = useRef([]);
695
+ const [, wake] = useReducer((count) => count + 1, 0);
696
+ const editable = !disabled && !readOnly;
697
+ const showToolbar = toolbar && !readOnly;
698
+ const latest = useRef({
699
+ mention,
700
+ maxLength,
701
+ onChange,
702
+ onPasteFiles,
703
+ toolbar: showToolbar
704
+ });
705
+ latest.current = {
706
+ mention,
707
+ maxLength,
708
+ onChange,
709
+ onPasteFiles,
710
+ toolbar: showToolbar
711
+ };
712
+ const host = useMemo(() => ({
713
+ mentionEnabled: () => latest.current.mention !== void 0,
714
+ maxLength: () => latest.current.maxLength,
715
+ toText: () => latest.current.mention?.toText ?? defaultMentionToText,
716
+ onSuggestion: (props) => {
717
+ if (props === null) setActiveOption(void 0);
718
+ setSuggestion(props === null ? null : {
719
+ query: props.query,
720
+ rect: props.clientRect ?? null,
721
+ command: props.command
722
+ });
723
+ },
724
+ onSuggestionKey: ({ event }) => refKeys.current?.(event) ?? false
725
+ }), []);
726
+ const extensions = useMemo(() => buildExtensions(host), [host]);
727
+ const emit = useCallback((current) => {
728
+ const html = isDocumentEmpty(current.state.doc) ? "" : serializeDocument(current.state.doc, host.toText());
729
+ refKnown.current = html;
730
+ return html;
731
+ }, [host]);
732
+ const attributes = useMemo(() => {
733
+ const entries = {
734
+ role: "textbox",
735
+ "aria-multiline": "true",
736
+ "aria-autocomplete": mention ? "list" : void 0,
737
+ "aria-controls": suggestion ? listId : void 0,
738
+ "aria-activedescendant": suggestion ? activeOption : void 0,
739
+ "aria-label": ariaLabel,
740
+ "aria-labelledby": ariaLabelledBy,
741
+ "aria-describedby": ariaDescribedBy,
742
+ "aria-invalid": isInvalid ? "true" : void 0,
743
+ "aria-readonly": readOnly ? "true" : void 0,
744
+ "aria-disabled": disabled ? "true" : void 0,
745
+ "aria-placeholder": placeholder,
746
+ tabindex: readOnly && !disabled ? "0" : void 0,
747
+ id,
748
+ "data-slot": "rich-text-editor-content",
749
+ class: cn(RICH_TEXT_BODY_CLASS, "min-h-(--rich-text-min) px-2.5 py-2 outline-none")
750
+ };
751
+ return Object.fromEntries(Object.entries(entries).filter((entry) => entry[1] !== void 0));
752
+ }, [
753
+ activeOption,
754
+ ariaDescribedBy,
755
+ ariaLabel,
756
+ ariaLabelledBy,
757
+ disabled,
758
+ id,
759
+ isInvalid,
760
+ listId,
761
+ mention,
762
+ placeholder,
763
+ readOnly,
764
+ suggestion
765
+ ]);
766
+ const editor = useEditor({
767
+ extensions,
768
+ content: value ?? defaultValue,
769
+ editable,
770
+ autofocus: autoFocus ? "end" : false,
771
+ editorProps: {
772
+ attributes,
773
+ handlePaste: (_view, event) => {
774
+ const data = event.clipboardData;
775
+ if (!(data !== null && data.files.length > 0 && data.getData("text/plain") === "" && data.getData("text/html") === "") || !latest.current.onPasteFiles) return false;
776
+ latest.current.onPasteFiles(Array.from(data.files));
777
+ return true;
778
+ },
779
+ handleKeyDown: (view, event) => {
780
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "k" && latest.current.toolbar) {
781
+ event.preventDefault();
782
+ refLinkRange.current = {
783
+ from: view.state.selection.from,
784
+ to: view.state.selection.to
785
+ };
786
+ setLinkOpen(true);
787
+ return true;
788
+ }
789
+ return false;
790
+ }
791
+ },
792
+ onBlur: () => {
793
+ setSuggestion(null);
794
+ setActiveOption(void 0);
795
+ },
796
+ onUpdate: ({ editor: current }) => {
797
+ latest.current.onChange?.(emit(current));
798
+ }
799
+ });
800
+ const ready = isLive(editor);
801
+ useEffect(() => {
802
+ editor.on("create", wake);
803
+ wake();
804
+ return () => {
805
+ editor.off("create", wake);
806
+ };
807
+ }, [editor]);
808
+ const whenLive = useCallback((action) => {
809
+ if (isLive(editor)) action(editor);
810
+ else refPending.current.push(action);
811
+ }, [editor]);
812
+ useImperativeHandle(ref, () => ({
813
+ insertText: (text) => {
814
+ whenLive((live) => {
815
+ live.chain().focus().insertContent({
816
+ type: "text",
817
+ text
818
+ }).run();
819
+ });
820
+ },
821
+ focus: () => {
822
+ whenLive((live) => {
823
+ live.commands.focus();
824
+ });
825
+ }
826
+ }), [whenLive]);
827
+ useEffect(() => {
828
+ if (!ready || !isLive(editor)) return;
829
+ for (const action of refPending.current.splice(0)) action(editor);
830
+ }, [editor, ready]);
831
+ useEffect(() => {
832
+ if (ready) syncEditable(editor, editable);
833
+ }, [
834
+ editable,
835
+ editor,
836
+ ready
837
+ ]);
838
+ useEffect(() => {
839
+ if (ready) syncValue(editor, value, refKnown);
840
+ }, [
841
+ editor,
842
+ ready,
843
+ value
844
+ ]);
845
+ const empty = useEditorState({
846
+ editor,
847
+ selector: ({ editor: current }) => isDocumentEmpty(current.state.doc)
848
+ });
849
+ const pick = useCallback((item) => {
850
+ suggestion?.command(item);
851
+ }, [suggestion]);
852
+ return /* @__PURE__ */ jsxs("div", {
853
+ "data-slot": "rich-text-editor",
854
+ "data-invalid": isInvalid ? "" : void 0,
855
+ "data-disabled": disabled ? "" : void 0,
856
+ "data-readonly": readOnly ? "" : void 0,
857
+ 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),
858
+ ...props,
859
+ children: [
860
+ showToolbar ? /* @__PURE__ */ jsx(RichTextToolbar, {
861
+ editor,
862
+ labels: text,
863
+ fields,
864
+ disabled,
865
+ linkOpen,
866
+ linkRange: refLinkRange,
867
+ onLinkOpenChange: (next) => {
868
+ if (next && !linkOpen) refLinkRange.current = {
869
+ from: editor.state.selection.from,
870
+ to: editor.state.selection.to
871
+ };
872
+ setLinkOpen(next);
873
+ }
874
+ }) : null,
875
+ /* @__PURE__ */ jsxs(ScrollArea, {
876
+ className: "relative",
877
+ style: {
878
+ maxHeight: `${maxRows * LINE + 1}rem`,
879
+ ["--rich-text-min"]: `${minRows * LINE + 1}rem`
880
+ },
881
+ focusable: false,
882
+ children: [empty && placeholder ? /* @__PURE__ */ jsx("p", {
883
+ "aria-hidden": true,
884
+ className: "pointer-events-none absolute start-2.5 top-2 text-muted-foreground",
885
+ children: placeholder
886
+ }) : null, /* @__PURE__ */ jsx(EditorContent, { editor })]
887
+ }),
888
+ suggestion && mention ? /* @__PURE__ */ jsx(MentionList, {
889
+ query: suggestion.query,
890
+ rect: suggestion.rect,
891
+ config: mention,
892
+ labels: text,
893
+ listId,
894
+ onPick: pick,
895
+ keyHandler: refKeys,
896
+ onActiveChange: setActiveOption
897
+ }) : null
898
+ ]
899
+ });
900
+ };
901
+ //#endregion
902
+ export { DEFAULT_RICH_TEXT_LABELS, RICH_TEXT_ATTRIBUTES, RICH_TEXT_ELEMENTS, RICH_TEXT_LINK_PROTOCOLS, RichTextEditor };
903
+
904
+ //# sourceMappingURL=rich-text-editor.js.map