@tutti-os/ui-rich-text 0.0.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,1738 @@
1
+ import {
2
+ createDefaultRichTextI18nRuntime
3
+ } from "../chunk-VQHCWUBH.js";
4
+ import {
5
+ createRichTextAtRegistry,
6
+ createRichTextMentionAttrs,
7
+ getRichTextMentionDisplayText,
8
+ renderRichTextAtInsertResult,
9
+ resolveRichTextMentionView
10
+ } from "../chunk-K5POY2YJ.js";
11
+ import {
12
+ findRichTextMarkdownLinks,
13
+ isRichTextMentionHref,
14
+ mentionReferenceNodeName,
15
+ normalizeRichTextContent,
16
+ normalizeRichTextLinkHref,
17
+ parseRichTextContentToDocument,
18
+ parseRichTextMentionHref,
19
+ serializeRichTextDocumentToContent,
20
+ workspaceReferenceNodeName
21
+ } from "../chunk-52PIIFZA.js";
22
+
23
+ // src/editor/RichTextAtEditor.tsx
24
+ import {
25
+ useEffect as useEffect2,
26
+ useLayoutEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState as useState2
30
+ } from "react";
31
+ import Document from "@tiptap/extension-document";
32
+ import HardBreak from "@tiptap/extension-hard-break";
33
+ import Paragraph from "@tiptap/extension-paragraph";
34
+ import Text from "@tiptap/extension-text";
35
+ import { EditorContent, useEditor } from "@tiptap/react";
36
+ import { ViewportMenuSurface } from "@tutti-os/ui-system/components";
37
+ import { cn } from "@tutti-os/ui-system/utils";
38
+
39
+ // src/editor/richTextAtQuery.ts
40
+ function isTriggerPrefixBoundary(character) {
41
+ return /[\s,;:!?<>{}|\\'"`~()[\]]/.test(character);
42
+ }
43
+ function findRichTextAtQuery(value, caret) {
44
+ const cursor = Math.max(0, Math.min(caret, value.length));
45
+ let segmentStart = cursor;
46
+ while (segmentStart > 0) {
47
+ const previous = value[segmentStart - 1] ?? "";
48
+ if (/\s/.test(previous)) {
49
+ break;
50
+ }
51
+ segmentStart -= 1;
52
+ }
53
+ const segment = value.slice(segmentStart, cursor);
54
+ for (let index = segment.lastIndexOf("@"); index >= 0; index = segment.lastIndexOf("@", index - 1)) {
55
+ const previous = segment[index - 1] ?? "";
56
+ if (index > 0 && !isTriggerPrefixBoundary(previous)) {
57
+ continue;
58
+ }
59
+ const candidate = segment.slice(index);
60
+ if (/[[\]()]/.test(candidate.slice(1))) {
61
+ return null;
62
+ }
63
+ return {
64
+ from: segmentStart + index,
65
+ to: cursor,
66
+ keyword: candidate.slice(1)
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+ async function queryRichTextAtMatches(registry, input) {
72
+ try {
73
+ return await registry.query(input);
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ // src/editor/richTextIme.ts
80
+ function isRichTextImeComposing(event) {
81
+ if (event.isComposing || event.nativeEvent?.isComposing) {
82
+ return true;
83
+ }
84
+ const keyCode = event.keyCode ?? event.nativeEvent?.keyCode;
85
+ const which = event.which ?? event.nativeEvent?.which;
86
+ return keyCode === 229 || which === 229;
87
+ }
88
+
89
+ // src/editor/richTextAtText.ts
90
+ var defaultRichTextI18n = createDefaultRichTextI18nRuntime();
91
+ function resolveRichTextAtText(overrides, removeDecorationAriaLabel, i18n = defaultRichTextI18n) {
92
+ return {
93
+ loadingLabel: overrides?.loadingLabel?.trim() || i18n.t("richTextAt.loading"),
94
+ noMatchesLabel: overrides?.noMatchesLabel?.trim() || i18n.t("richTextAt.noMatches"),
95
+ removeReferenceActionLabel: removeDecorationAriaLabel?.trim() || overrides?.removeReferenceActionLabel?.trim() || i18n.t("richTextAt.removeReferenceActionLabel")
96
+ };
97
+ }
98
+ var defaultRichTextAtText = resolveRichTextAtText();
99
+
100
+ // src/extensions/mentionReference.ts
101
+ import { mergeAttributes, Node } from "@tiptap/core";
102
+ import { ReactNodeViewRenderer } from "@tiptap/react";
103
+
104
+ // src/extensions/MentionReferenceNodeView.tsx
105
+ import { NodeViewWrapper } from "@tiptap/react";
106
+ import { jsx, jsxs } from "react/jsx-runtime";
107
+ function MentionReferenceNodeView({
108
+ node,
109
+ selected
110
+ }) {
111
+ const label = typeof node.attrs.label === "string" ? node.attrs.label.trim() : "";
112
+ return /* @__PURE__ */ jsx(
113
+ NodeViewWrapper,
114
+ {
115
+ as: "span",
116
+ className: `inline-flex max-w-full align-baseline ${selected ? "is-selected" : ""}`,
117
+ contentEditable: false,
118
+ "data-rich-text-mention-reference": "true",
119
+ children: /* @__PURE__ */ jsxs(
120
+ "span",
121
+ {
122
+ className: `inline-flex max-w-full items-center overflow-hidden rounded-md px-1.5 py-0.5 text-sm font-medium ${selected ? "bg-[var(--background-fronted)] text-[var(--text-primary)] shadow-soft" : "bg-transparency-block text-[var(--text-primary)]"}`,
123
+ children: [
124
+ "@",
125
+ label
126
+ ]
127
+ }
128
+ )
129
+ }
130
+ );
131
+ }
132
+
133
+ // src/extensions/mentionReference.ts
134
+ var MentionReference = Node.create({
135
+ name: mentionReferenceNodeName,
136
+ group: "inline",
137
+ inline: true,
138
+ atom: true,
139
+ selectable: true,
140
+ addAttributes() {
141
+ return {
142
+ entityId: {
143
+ default: ""
144
+ },
145
+ href: {
146
+ default: null
147
+ },
148
+ kind: {
149
+ default: null
150
+ },
151
+ label: {
152
+ default: ""
153
+ },
154
+ meta: {
155
+ default: null
156
+ },
157
+ plugin: {
158
+ default: ""
159
+ },
160
+ trigger: {
161
+ default: "@"
162
+ },
163
+ version: {
164
+ default: null
165
+ }
166
+ };
167
+ },
168
+ parseHTML() {
169
+ return [{ tag: "span[data-rich-text-mention-reference]" }];
170
+ },
171
+ renderHTML({ HTMLAttributes }) {
172
+ const label = typeof HTMLAttributes.label === "string" ? HTMLAttributes.label : "";
173
+ return [
174
+ "span",
175
+ mergeAttributes(HTMLAttributes, {
176
+ "data-rich-text-mention-reference": "true",
177
+ class: "inline-flex max-w-full items-center overflow-hidden rounded-md bg-transparency-block px-1.5 py-0.5 align-baseline text-sm font-medium text-[var(--text-primary)]"
178
+ }),
179
+ `@${label}`
180
+ ];
181
+ },
182
+ addNodeView() {
183
+ return ReactNodeViewRenderer(MentionReferenceNodeView);
184
+ }
185
+ });
186
+
187
+ // src/extensions/workspaceReference.ts
188
+ import { mergeAttributes as mergeAttributes2, Node as Node2 } from "@tiptap/core";
189
+ import { ReactNodeViewRenderer as ReactNodeViewRenderer2 } from "@tiptap/react";
190
+
191
+ // src/extensions/WorkspaceReferenceNodeView.tsx
192
+ import { useEffect, useState } from "react";
193
+ import { NodeViewWrapper as NodeViewWrapper2 } from "@tiptap/react";
194
+ import {
195
+ MentionPill,
196
+ Tooltip,
197
+ TooltipContent,
198
+ TooltipTrigger
199
+ } from "@tutti-os/ui-system/components";
200
+
201
+ // src/extensions/workspaceReferencePresentation.ts
202
+ function getWorkspaceReferencePresentation(label, path) {
203
+ const displayLabel = normalizeReferenceLabel(label, path);
204
+ const fullPath = normalizeFullPath(path);
205
+ return {
206
+ displayLabel,
207
+ fullPath
208
+ };
209
+ }
210
+ function normalizeReferenceLabel(label, path) {
211
+ const trimmedLabel = label.trim();
212
+ if (trimmedLabel !== "") {
213
+ return trimmedLabel;
214
+ }
215
+ return getPathBasename(path) || path.trim();
216
+ }
217
+ function normalizeFullPath(path) {
218
+ return trimTrailingSeparators(path.trim());
219
+ }
220
+ function getPathBasename(path) {
221
+ const normalizedPath = trimTrailingSeparators(path.trim());
222
+ if (normalizedPath === "" || isPathRoot(normalizedPath)) {
223
+ return normalizedPath;
224
+ }
225
+ const segments = normalizedPath.split(/[\\/]+/).filter(Boolean);
226
+ return segments.at(-1) ?? "";
227
+ }
228
+ function trimTrailingSeparators(path) {
229
+ if (path === "") {
230
+ return "";
231
+ }
232
+ if (isPathRoot(path)) {
233
+ return path;
234
+ }
235
+ return path.replace(/[\\/]+$/, "");
236
+ }
237
+ function isPathRoot(path) {
238
+ return path === "/" || path === "\\" || path === "//" || path === "\\\\" || /^[A-Za-z]:[\\/]?$/.test(path);
239
+ }
240
+
241
+ // src/extensions/WorkspaceReferenceNodeView.tsx
242
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
243
+ var richTextWorkspaceReferencePillClassName = "max-w-[18rem]";
244
+ function WorkspaceReferenceNodeView({
245
+ deleteNode,
246
+ editor,
247
+ extension,
248
+ node,
249
+ selected
250
+ }) {
251
+ const [isEditable, setIsEditable] = useState(editor.isEditable);
252
+ const attrs = node.attrs;
253
+ const extensionOptions = extension.options;
254
+ const kind = attrs.kind === "folder" ? "folder" : "file";
255
+ const label = typeof attrs.label === "string" ? attrs.label : "";
256
+ const path = typeof attrs.path === "string" ? attrs.path : "";
257
+ const presentation = getWorkspaceReferencePresentation(label, path);
258
+ const removeActionAriaLabel = typeof extensionOptions.removeActionAriaLabel === "string" ? extensionOptions.removeActionAriaLabel : defaultRichTextAtText.removeReferenceActionLabel;
259
+ useEffect(() => {
260
+ const syncEditable = () => {
261
+ setIsEditable(editor.isEditable);
262
+ };
263
+ syncEditable();
264
+ editor.on("transaction", syncEditable);
265
+ editor.on("update", syncEditable);
266
+ return () => {
267
+ editor.off("transaction", syncEditable);
268
+ editor.off("update", syncEditable);
269
+ };
270
+ }, [editor]);
271
+ const handleRemove = (event) => {
272
+ event.preventDefault();
273
+ event.stopPropagation();
274
+ if (!editor.isEditable) {
275
+ return;
276
+ }
277
+ deleteNode();
278
+ };
279
+ return /* @__PURE__ */ jsx2(
280
+ NodeViewWrapper2,
281
+ {
282
+ as: "span",
283
+ className: `inline-flex max-w-full align-baseline ${selected ? "is-selected" : ""}`,
284
+ contentEditable: false,
285
+ "data-rich-text-workspace-reference": "true",
286
+ children: /* @__PURE__ */ jsxs2(Tooltip, { children: [
287
+ /* @__PURE__ */ jsx2(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx2(
288
+ MentionPill,
289
+ {
290
+ className: richTextWorkspaceReferencePillClassName,
291
+ fileKind: kind,
292
+ kind: "file",
293
+ label: presentation.displayLabel,
294
+ removable: isEditable,
295
+ removeButtonProps: isEditable ? {
296
+ "aria-label": removeActionAriaLabel,
297
+ onMouseDown: handleRemove
298
+ } : void 0
299
+ }
300
+ ) }),
301
+ /* @__PURE__ */ jsx2(
302
+ TooltipContent,
303
+ {
304
+ className: "max-w-md whitespace-normal break-all",
305
+ sideOffset: 8,
306
+ children: presentation.fullPath
307
+ }
308
+ )
309
+ ] })
310
+ }
311
+ );
312
+ }
313
+
314
+ // src/extensions/workspaceReference.ts
315
+ var WorkspaceReference = Node2.create({
316
+ name: workspaceReferenceNodeName,
317
+ addOptions() {
318
+ return {
319
+ removeActionAriaLabel: defaultRichTextAtText.removeReferenceActionLabel
320
+ };
321
+ },
322
+ group: "inline",
323
+ inline: true,
324
+ atom: true,
325
+ selectable: true,
326
+ addAttributes() {
327
+ return {
328
+ kind: {
329
+ default: "file"
330
+ },
331
+ label: {
332
+ default: ""
333
+ },
334
+ path: {
335
+ default: ""
336
+ }
337
+ };
338
+ },
339
+ parseHTML() {
340
+ return [{ tag: "span[data-rich-text-workspace-reference]" }];
341
+ },
342
+ renderHTML({ HTMLAttributes }) {
343
+ const kind = HTMLAttributes.kind === "folder" ? "folder" : "file";
344
+ const label = typeof HTMLAttributes.label === "string" ? HTMLAttributes.label : "";
345
+ const path = typeof HTMLAttributes.path === "string" ? HTMLAttributes.path : "";
346
+ const presentation = getWorkspaceReferencePresentation(label, path);
347
+ const referenceColorClassName = kind === "folder" ? "text-[var(--rich-text-folder)]" : "text-[var(--rich-text-mention-file)]";
348
+ return [
349
+ "span",
350
+ mergeAttributes2(HTMLAttributes, {
351
+ "data-rich-text-workspace-reference": "true",
352
+ "data-rich-text-workspace-kind": kind,
353
+ "data-agent-file-mention": "true",
354
+ "data-agent-mention-kind": "file",
355
+ "data-slot": "mention-pill",
356
+ title: presentation.fullPath,
357
+ class: [
358
+ "group relative top-[3px] inline-flex max-w-full cursor-default items-center gap-1.5 overflow-hidden rounded-[4px] border border-transparent bg-transparent px-1.5 py-0.5 align-baseline text-sm font-medium leading-5 no-underline transition-colors hover:border-transparent hover:bg-[color-mix(in_srgb,currentColor_12%,transparent)]",
359
+ referenceColorClassName
360
+ ].join(" ")
361
+ }),
362
+ [
363
+ "span",
364
+ {
365
+ "aria-hidden": "true",
366
+ class: "grid size-4 shrink-0 place-items-center text-current"
367
+ },
368
+ kind === "folder" ? "D" : "F"
369
+ ],
370
+ [
371
+ "span",
372
+ {
373
+ class: "min-w-0 max-w-[20rem] truncate text-sm font-medium"
374
+ },
375
+ presentation.displayLabel
376
+ ]
377
+ ];
378
+ },
379
+ addNodeView() {
380
+ return ReactNodeViewRenderer2(WorkspaceReferenceNodeView);
381
+ }
382
+ });
383
+
384
+ // src/editor/RichTextAtEditor.tsx
385
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
386
+ function RichTextAtEditor({
387
+ value,
388
+ onChange,
389
+ providers = [],
390
+ placeholder,
391
+ disabled = false,
392
+ className,
393
+ textareaClassName,
394
+ placeholderClassName,
395
+ minQueryLength = 0,
396
+ maxResults = 8,
397
+ removeDecorationAriaLabel,
398
+ i18n,
399
+ textOverrides,
400
+ overlay,
401
+ focusSignal
402
+ }) {
403
+ const menuOffset = 6;
404
+ const normalizedValue = normalizeRichTextContent(value);
405
+ const text = resolveRichTextAtText(
406
+ textOverrides,
407
+ removeDecorationAriaLabel,
408
+ i18n
409
+ );
410
+ const latestOnChangeRef = useRef(onChange);
411
+ const lastSerializedValueRef = useRef(normalizedValue);
412
+ const lastFocusSignalRef = useRef(focusSignal);
413
+ const containerRef = useRef(null);
414
+ const registry = useMemo(
415
+ () => createRichTextAtRegistry(providers),
416
+ [providers]
417
+ );
418
+ const [isFocused, setIsFocused] = useState2(false);
419
+ const [query, setQuery] = useState2(null);
420
+ const [matches, setMatches] = useState2([]);
421
+ const [activeIndex, setActiveIndex] = useState2(0);
422
+ const [isLoading, setIsLoading] = useState2(false);
423
+ const [menuPoint, setMenuPoint] = useState2(
424
+ null
425
+ );
426
+ latestOnChangeRef.current = onChange;
427
+ const editor = useEditor({
428
+ immediatelyRender: false,
429
+ editable: !disabled,
430
+ extensions: [
431
+ Document,
432
+ Paragraph,
433
+ Text,
434
+ HardBreak,
435
+ WorkspaceReference.configure({
436
+ removeActionAriaLabel: text.removeReferenceActionLabel
437
+ }),
438
+ MentionReference
439
+ ],
440
+ content: parseRichTextContentToDocument(normalizedValue),
441
+ editorProps: {
442
+ attributes: {
443
+ class: cn(
444
+ "w-full whitespace-pre-wrap break-words outline-none",
445
+ textareaClassName
446
+ )
447
+ }
448
+ },
449
+ onBlur() {
450
+ window.setTimeout(() => {
451
+ setIsFocused(false);
452
+ setMatches([]);
453
+ setActiveIndex(0);
454
+ setIsLoading(false);
455
+ setMenuPoint(null);
456
+ }, 100);
457
+ },
458
+ onFocus() {
459
+ setIsFocused(true);
460
+ },
461
+ onUpdate({ editor: editor2 }) {
462
+ const serialized = serializeRichTextDocumentToContent(editor2.getJSON());
463
+ lastSerializedValueRef.current = serialized;
464
+ latestOnChangeRef.current(serialized);
465
+ }
466
+ });
467
+ useEffect2(() => {
468
+ if (!editor) {
469
+ return;
470
+ }
471
+ if (lastSerializedValueRef.current === normalizedValue) {
472
+ return;
473
+ }
474
+ const currentSerialized = serializeRichTextDocumentToContent(
475
+ editor.getJSON()
476
+ );
477
+ if (currentSerialized === normalizedValue) {
478
+ lastSerializedValueRef.current = normalizedValue;
479
+ return;
480
+ }
481
+ editor.commands.setContent(
482
+ parseRichTextContentToDocument(normalizedValue),
483
+ {
484
+ emitUpdate: false
485
+ }
486
+ );
487
+ lastSerializedValueRef.current = normalizedValue;
488
+ }, [editor, normalizedValue]);
489
+ useEffect2(() => {
490
+ if (!editor) {
491
+ return;
492
+ }
493
+ if (Object.is(lastFocusSignalRef.current, focusSignal)) {
494
+ return;
495
+ }
496
+ lastFocusSignalRef.current = focusSignal;
497
+ editor.commands.focus("end");
498
+ }, [editor, focusSignal]);
499
+ useEffect2(() => {
500
+ if (!editor) {
501
+ return;
502
+ }
503
+ editor.setEditable(!disabled);
504
+ editor.view.dispatch(
505
+ editor.state.tr.setMeta("richTextEditable", !disabled)
506
+ );
507
+ }, [disabled, editor]);
508
+ useEffect2(() => {
509
+ if (!editor) {
510
+ return;
511
+ }
512
+ const updateQueryState = () => {
513
+ const nextQuery = isFocused ? findEditorAtQuery(editor) : null;
514
+ setQuery(nextQuery);
515
+ };
516
+ const updateFocus = () => {
517
+ setIsFocused(editor.isFocused);
518
+ updateQueryState();
519
+ };
520
+ updateQueryState();
521
+ editor.on("selectionUpdate", updateQueryState);
522
+ editor.on("transaction", updateQueryState);
523
+ editor.on("focus", updateFocus);
524
+ editor.on("blur", updateFocus);
525
+ return () => {
526
+ editor.off("selectionUpdate", updateQueryState);
527
+ editor.off("transaction", updateQueryState);
528
+ editor.off("focus", updateFocus);
529
+ editor.off("blur", updateFocus);
530
+ };
531
+ }, [editor, isFocused]);
532
+ useEffect2(() => {
533
+ if (!editor || !query || !isFocused || providers.length === 0) {
534
+ setMatches([]);
535
+ setActiveIndex(0);
536
+ setIsLoading(false);
537
+ return;
538
+ }
539
+ if (query.keyword.length < minQueryLength) {
540
+ setMatches([]);
541
+ setActiveIndex(0);
542
+ setIsLoading(false);
543
+ return;
544
+ }
545
+ const abortController = new AbortController();
546
+ setIsLoading(true);
547
+ void queryRichTextAtMatches(registry, {
548
+ abortSignal: abortController.signal,
549
+ keyword: query.keyword,
550
+ maxResults,
551
+ context: {
552
+ blockText: editor.state.selection.$from.parent.textBetween(
553
+ 0,
554
+ editor.state.selection.$from.parent.content.size,
555
+ "\n",
556
+ "\uFFFC"
557
+ ),
558
+ documentText: serializeRichTextDocumentToContent(editor.getJSON())
559
+ }
560
+ }).then((nextMatches) => {
561
+ if (abortController.signal.aborted) {
562
+ return;
563
+ }
564
+ setMatches(nextMatches);
565
+ setActiveIndex(
566
+ (current) => nextMatches.length === 0 ? 0 : Math.max(0, Math.min(current, nextMatches.length - 1))
567
+ );
568
+ }).finally(() => {
569
+ if (!abortController.signal.aborted) {
570
+ setIsLoading(false);
571
+ }
572
+ });
573
+ return () => {
574
+ abortController.abort();
575
+ };
576
+ }, [
577
+ editor,
578
+ isFocused,
579
+ maxResults,
580
+ minQueryLength,
581
+ providers.length,
582
+ query,
583
+ registry
584
+ ]);
585
+ useLayoutEffect(() => {
586
+ if (!editor || !query || !isFocused) {
587
+ setMenuPoint(null);
588
+ return;
589
+ }
590
+ const updateMenuPoint = () => {
591
+ const coords = editor.view.coordsAtPos(editor.state.selection.from);
592
+ setMenuPoint({
593
+ x: coords.left,
594
+ y: coords.bottom + menuOffset
595
+ });
596
+ };
597
+ updateMenuPoint();
598
+ window.addEventListener("resize", updateMenuPoint);
599
+ window.addEventListener("scroll", updateMenuPoint, {
600
+ capture: true,
601
+ passive: true
602
+ });
603
+ return () => {
604
+ window.removeEventListener("resize", updateMenuPoint);
605
+ window.removeEventListener("scroll", updateMenuPoint, true);
606
+ };
607
+ }, [editor, isFocused, menuOffset, query]);
608
+ const isMenuOpen = isFocused && !!query && (isLoading || matches.length > 0);
609
+ const isEmpty = !editor || serializeRichTextDocumentToContent(editor.getJSON()).trim().length === 0;
610
+ const applyMatch = (match) => {
611
+ if (!editor || !query) {
612
+ return;
613
+ }
614
+ const content = renderInsertResultAsEditorContent(
615
+ match.providerId,
616
+ match.insertResult
617
+ );
618
+ if (!content) {
619
+ return;
620
+ }
621
+ editor.chain().focus().insertContentAt({ from: query.from, to: query.to }, content).run();
622
+ setMatches([]);
623
+ setActiveIndex(0);
624
+ setIsLoading(false);
625
+ setMenuPoint(null);
626
+ };
627
+ const handleKeyDown = (event) => {
628
+ if (isRichTextImeComposing(event)) {
629
+ return;
630
+ }
631
+ if (!isMenuOpen || matches.length === 0) {
632
+ return;
633
+ }
634
+ if (event.key === "ArrowDown") {
635
+ event.preventDefault();
636
+ setActiveIndex((current) => (current + 1) % matches.length);
637
+ return;
638
+ }
639
+ if (event.key === "ArrowUp") {
640
+ event.preventDefault();
641
+ setActiveIndex(
642
+ (current) => (current - 1 + matches.length) % matches.length
643
+ );
644
+ return;
645
+ }
646
+ if (event.key === "Enter" || event.key === "Tab") {
647
+ const match = matches[activeIndex];
648
+ if (!match) {
649
+ return;
650
+ }
651
+ event.preventDefault();
652
+ applyMatch(match);
653
+ return;
654
+ }
655
+ if (event.key === "Escape") {
656
+ event.preventDefault();
657
+ setMatches([]);
658
+ setActiveIndex(0);
659
+ setIsLoading(false);
660
+ setMenuPoint(null);
661
+ }
662
+ };
663
+ return /* @__PURE__ */ jsxs3(
664
+ "div",
665
+ {
666
+ className: cn("relative min-w-0 w-full", className),
667
+ ref: containerRef,
668
+ children: [
669
+ /* @__PURE__ */ jsx3("div", { className: "w-full min-w-0", onKeyDownCapture: handleKeyDown, children: /* @__PURE__ */ jsx3(EditorContent, { editor }) }),
670
+ isEmpty && placeholder ? /* @__PURE__ */ jsx3("div", { className: "pointer-events-none absolute top-0 right-0 left-0 px-0 py-0 text-[var(--text-placeholder)]", children: /* @__PURE__ */ jsx3(
671
+ "div",
672
+ {
673
+ className: cn(
674
+ "min-w-0 w-full whitespace-pre-wrap",
675
+ placeholderClassName ?? textareaClassName,
676
+ "text-[var(--text-placeholder)]"
677
+ ),
678
+ children: placeholder
679
+ }
680
+ ) }) : null,
681
+ overlay,
682
+ isMenuOpen && menuPoint ? /* @__PURE__ */ jsx3(
683
+ ViewportMenuSurface,
684
+ {
685
+ open: true,
686
+ className: "nextop-rich-text-at-menu max-h-64 w-[min(28rem,calc(100vw-24px))] overflow-y-auto p-1",
687
+ placement: {
688
+ type: "point",
689
+ point: menuPoint,
690
+ alignX: "start",
691
+ alignY: "start",
692
+ estimatedSize: {
693
+ width: 360,
694
+ height: 256
695
+ }
696
+ },
697
+ children: matches.length > 0 ? matches.map((match, index) => /* @__PURE__ */ jsxs3(
698
+ "button",
699
+ {
700
+ "aria-selected": index === activeIndex,
701
+ className: cn(
702
+ "flex w-full cursor-pointer flex-col items-start gap-0.5 rounded-md px-2.5 py-2 text-left outline-none transition-colors",
703
+ index === activeIndex ? "bg-transparency-block text-[var(--text-primary)]" : "text-[var(--text-primary)] hover:bg-transparency-block"
704
+ ),
705
+ type: "button",
706
+ onMouseDown: (event) => {
707
+ event.preventDefault();
708
+ applyMatch(match);
709
+ },
710
+ children: [
711
+ /* @__PURE__ */ jsx3("div", { className: "text-sm leading-5 font-medium", children: match.label }),
712
+ match.subtitle ? /* @__PURE__ */ jsx3("div", { className: "text-xs leading-4 text-[var(--text-secondary)]", children: match.subtitle }) : null
713
+ ]
714
+ },
715
+ `${match.providerId}:${match.key}`
716
+ )) : /* @__PURE__ */ jsx3("div", { className: "px-3 py-2 text-xs leading-4 text-[var(--text-secondary)]", children: isLoading ? text.loadingLabel : text.noMatchesLabel })
717
+ }
718
+ ) : null
719
+ ]
720
+ }
721
+ );
722
+ }
723
+ function findEditorAtQuery(editor) {
724
+ const { selection } = editor.state;
725
+ if (!selection.empty) {
726
+ return null;
727
+ }
728
+ const { $from } = selection;
729
+ if (!$from.parent.isTextblock) {
730
+ return null;
731
+ }
732
+ const textBeforeCursor = $from.parent.textBetween(
733
+ 0,
734
+ $from.parentOffset,
735
+ "\n",
736
+ "\uFFFC"
737
+ );
738
+ const query = findRichTextAtQuery(textBeforeCursor, textBeforeCursor.length);
739
+ if (!query) {
740
+ return null;
741
+ }
742
+ const distanceFromQueryStart = textBeforeCursor.length - query.from;
743
+ return {
744
+ from: selection.from - distanceFromQueryStart,
745
+ keyword: query.keyword,
746
+ to: selection.from
747
+ };
748
+ }
749
+ function renderInsertResultAsEditorContent(providerId, result) {
750
+ switch (result.kind) {
751
+ case "mention":
752
+ return {
753
+ type: mentionReferenceNodeName,
754
+ attrs: createRichTextMentionAttrs(providerId, result.mention)
755
+ };
756
+ case "markdown-link": {
757
+ const kind = result.href.endsWith("/") ? "folder" : "file";
758
+ return {
759
+ type: workspaceReferenceNodeName,
760
+ attrs: {
761
+ kind,
762
+ label: result.label,
763
+ path: normalizeRichTextLinkHref(result.href, kind)
764
+ }
765
+ };
766
+ }
767
+ case "text":
768
+ return result.text;
769
+ default:
770
+ return null;
771
+ }
772
+ }
773
+
774
+ // src/editor/RichTextAtTextarea.tsx
775
+ import {
776
+ useEffect as useEffect3,
777
+ useLayoutEffect as useLayoutEffect2,
778
+ useMemo as useMemo2,
779
+ useRef as useRef2,
780
+ useState as useState3
781
+ } from "react";
782
+ import { ViewportMenuSurface as ViewportMenuSurface2 } from "@tutti-os/ui-system/components";
783
+ import { cn as cn3 } from "@tutti-os/ui-system/utils";
784
+
785
+ // src/editor/richTextTextareaDecorationModel.ts
786
+ var EXTERNAL_LINK_PREFIX = /^(?:[a-z]+:)?\/\//i;
787
+ var MENTION_LINK_PREFIX = /^mention:\/\//i;
788
+ var TEXTAREA_PRESENTATION_STYLE_PROPERTIES = [
789
+ { styleName: "paddingTop", cssName: "padding-top" },
790
+ { styleName: "paddingRight", cssName: "padding-right" },
791
+ { styleName: "paddingBottom", cssName: "padding-bottom" },
792
+ { styleName: "paddingLeft", cssName: "padding-left" },
793
+ { styleName: "fontFamily", cssName: "font-family" },
794
+ { styleName: "fontFeatureSettings", cssName: "font-feature-settings" },
795
+ { styleName: "fontKerning", cssName: "font-kerning" },
796
+ { styleName: "fontOpticalSizing", cssName: "font-optical-sizing" },
797
+ { styleName: "fontSize", cssName: "font-size" },
798
+ { styleName: "fontStretch", cssName: "font-stretch" },
799
+ { styleName: "fontStyle", cssName: "font-style" },
800
+ { styleName: "fontVariant", cssName: "font-variant" },
801
+ { styleName: "fontVariationSettings", cssName: "font-variation-settings" },
802
+ { styleName: "fontWeight", cssName: "font-weight" },
803
+ { styleName: "letterSpacing", cssName: "letter-spacing" },
804
+ { styleName: "lineHeight", cssName: "line-height" },
805
+ { styleName: "textAlign", cssName: "text-align" },
806
+ { styleName: "textIndent", cssName: "text-indent" },
807
+ { styleName: "textTransform", cssName: "text-transform" },
808
+ { styleName: "tabSize", cssName: "tab-size" },
809
+ { styleName: "MozTabSize", cssName: "-moz-tab-size" }
810
+ ];
811
+ function isDecoratableMarkdownHref(href) {
812
+ const trimmedHref = href.trim();
813
+ if (!trimmedHref) {
814
+ return false;
815
+ }
816
+ if (EXTERNAL_LINK_PREFIX.test(trimmedHref) || MENTION_LINK_PREFIX.test(trimmedHref)) {
817
+ return false;
818
+ }
819
+ return true;
820
+ }
821
+ function buildRichTextTextareaDecorationSegments(value) {
822
+ const segments = [];
823
+ let cursor = 0;
824
+ for (const match of findRichTextMarkdownLinks(value)) {
825
+ const label = match.label.trim();
826
+ const href = match.href.trim();
827
+ const { index, source, to } = match;
828
+ if (index > cursor) {
829
+ segments.push({
830
+ type: "text",
831
+ text: value.slice(cursor, index),
832
+ from: cursor,
833
+ to: index
834
+ });
835
+ }
836
+ if (label && href && isDecoratableMarkdownHref(href)) {
837
+ segments.push({
838
+ type: "link",
839
+ text: source,
840
+ from: index,
841
+ to,
842
+ label,
843
+ href,
844
+ kind: href.endsWith("/") ? "folder" : "file"
845
+ });
846
+ } else {
847
+ segments.push({
848
+ type: "text",
849
+ text: source,
850
+ from: index,
851
+ to
852
+ });
853
+ }
854
+ cursor = to;
855
+ }
856
+ if (cursor < value.length) {
857
+ segments.push({
858
+ type: "text",
859
+ text: value.slice(cursor),
860
+ from: cursor,
861
+ to: value.length
862
+ });
863
+ }
864
+ return segments;
865
+ }
866
+ function hasRichTextTextareaDecorations(segments) {
867
+ return segments.some((segment) => segment.type === "link");
868
+ }
869
+ function resolveRichTextTextareaSelectionBoundary(segments, selectionStart) {
870
+ for (const segment of segments) {
871
+ if (segment.type !== "link") {
872
+ continue;
873
+ }
874
+ if (selectionStart <= segment.from || selectionStart >= segment.to) {
875
+ continue;
876
+ }
877
+ const midpoint = segment.from + (segment.to - segment.from) / 2;
878
+ return selectionStart < midpoint ? segment.from : segment.to;
879
+ }
880
+ return null;
881
+ }
882
+ function setTextareaPresentationStyleValue(styleRecord, styleName, value) {
883
+ styleRecord[styleName] = value;
884
+ }
885
+ function getTextareaPresentationStyle(textarea) {
886
+ const computedStyle = window.getComputedStyle(textarea);
887
+ const styleRecord = {
888
+ whiteSpace: "pre-wrap"
889
+ };
890
+ setTextareaPresentationStyleValue(
891
+ styleRecord,
892
+ "wordBreak",
893
+ computedStyle.wordBreak
894
+ );
895
+ setTextareaPresentationStyleValue(
896
+ styleRecord,
897
+ "overflowWrap",
898
+ computedStyle.overflowWrap
899
+ );
900
+ for (const { styleName, cssName } of TEXTAREA_PRESENTATION_STYLE_PROPERTIES) {
901
+ setTextareaPresentationStyleValue(
902
+ styleRecord,
903
+ styleName,
904
+ computedStyle.getPropertyValue(cssName)
905
+ );
906
+ }
907
+ return styleRecord;
908
+ }
909
+
910
+ // src/editor/richTextTextareaDecorations.tsx
911
+ import { MentionPill as MentionPill2 } from "@tutti-os/ui-system/components";
912
+ import { cn as cn2 } from "@tutti-os/ui-system/utils";
913
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
914
+ var richTextWorkspaceReferencePillClassName2 = "max-w-[18rem]";
915
+ function RichTextTextareaDecoratedContent({
916
+ onClickSegment,
917
+ onRemoveSegment,
918
+ removeActionAriaLabel,
919
+ scrollLeft,
920
+ scrollTop,
921
+ segments,
922
+ textareaStyle
923
+ }) {
924
+ return /* @__PURE__ */ jsx4("div", { className: "pointer-events-none absolute inset-0 z-10 overflow-hidden", children: /* @__PURE__ */ jsx4(
925
+ "div",
926
+ {
927
+ className: "min-h-full min-w-full",
928
+ style: {
929
+ ...textareaStyle,
930
+ transform: `translate(${-scrollLeft}px, ${-scrollTop}px)`
931
+ },
932
+ children: segments.map((segment, index) => {
933
+ if (segment.type === "text") {
934
+ return /* @__PURE__ */ jsx4("span", { children: segment.text }, index);
935
+ }
936
+ return /* @__PURE__ */ jsxs4("span", { className: "relative", children: [
937
+ /* @__PURE__ */ jsx4("span", { "aria-hidden": "true", className: "text-transparent", children: segment.text }),
938
+ /* @__PURE__ */ jsx4(
939
+ MentionPill2,
940
+ {
941
+ className: cn2(
942
+ "pointer-events-auto absolute inset-y-0 left-0 pr-1 pl-1.5",
943
+ richTextWorkspaceReferencePillClassName2
944
+ ),
945
+ fileKind: segment.kind,
946
+ kind: "file",
947
+ label: segment.label,
948
+ removeButtonProps: {
949
+ "aria-label": removeActionAriaLabel,
950
+ onPointerDown: (event) => {
951
+ event.preventDefault();
952
+ event.stopPropagation();
953
+ onRemoveSegment(segment);
954
+ }
955
+ },
956
+ summary: /* @__PURE__ */ jsx4(
957
+ "span",
958
+ {
959
+ className: cn2(
960
+ "min-w-0 truncate text-xs text-current opacity-80",
961
+ segment.kind === "folder" ? "max-w-[18rem]" : "max-w-[20rem]"
962
+ ),
963
+ children: segment.href
964
+ }
965
+ ),
966
+ onPointerDown: (event) => {
967
+ onClickSegment(segment, event);
968
+ }
969
+ }
970
+ )
971
+ ] }, index);
972
+ })
973
+ }
974
+ ) });
975
+ }
976
+
977
+ // src/editor/richTextTextareaCaret.ts
978
+ var TEXTAREA_MIRROR_STYLE_PROPERTIES = [
979
+ "boxSizing",
980
+ "width",
981
+ "paddingTop",
982
+ "paddingRight",
983
+ "paddingBottom",
984
+ "paddingLeft",
985
+ "borderTopWidth",
986
+ "borderRightWidth",
987
+ "borderBottomWidth",
988
+ "borderLeftWidth",
989
+ "borderTopStyle",
990
+ "borderRightStyle",
991
+ "borderBottomStyle",
992
+ "borderLeftStyle",
993
+ "borderTopColor",
994
+ "borderRightColor",
995
+ "borderBottomColor",
996
+ "borderLeftColor",
997
+ "borderRadius",
998
+ "fontFamily",
999
+ "fontFeatureSettings",
1000
+ "fontKerning",
1001
+ "fontOpticalSizing",
1002
+ "fontSize",
1003
+ "fontStretch",
1004
+ "fontStyle",
1005
+ "fontVariant",
1006
+ "fontVariationSettings",
1007
+ "fontWeight",
1008
+ "letterSpacing",
1009
+ "lineHeight",
1010
+ "textAlign",
1011
+ "textDecoration",
1012
+ "textIndent",
1013
+ "textTransform",
1014
+ "wordBreak",
1015
+ "overflowWrap",
1016
+ "tabSize",
1017
+ "MozTabSize"
1018
+ ];
1019
+ function parsePixelValue(value) {
1020
+ if (!value || value === "normal") {
1021
+ return null;
1022
+ }
1023
+ const parsedValue = Number.parseFloat(value);
1024
+ return Number.isFinite(parsedValue) ? parsedValue : null;
1025
+ }
1026
+ function resolveLineHeight(computedStyle) {
1027
+ const explicitLineHeight = parsePixelValue(computedStyle.lineHeight);
1028
+ if (explicitLineHeight !== null) {
1029
+ return explicitLineHeight;
1030
+ }
1031
+ const fontSize = parsePixelValue(computedStyle.fontSize);
1032
+ return fontSize !== null ? fontSize * 1.4 : 20;
1033
+ }
1034
+ function getTextareaCaretViewportPoint(textarea, selectionStart) {
1035
+ if (typeof document === "undefined") {
1036
+ return null;
1037
+ }
1038
+ const safeSelectionStart = Math.max(
1039
+ 0,
1040
+ Math.min(selectionStart, textarea.value.length)
1041
+ );
1042
+ const textareaRect = textarea.getBoundingClientRect();
1043
+ const computedStyle = window.getComputedStyle(textarea);
1044
+ const mirror = document.createElement("div");
1045
+ const marker = document.createElement("span");
1046
+ mirror.setAttribute("aria-hidden", "true");
1047
+ mirror.style.position = "fixed";
1048
+ mirror.style.left = `${textareaRect.left}px`;
1049
+ mirror.style.top = `${textareaRect.top}px`;
1050
+ mirror.style.visibility = "hidden";
1051
+ mirror.style.pointerEvents = "none";
1052
+ mirror.style.overflow = "hidden";
1053
+ for (const propertyName of TEXTAREA_MIRROR_STYLE_PROPERTIES) {
1054
+ mirror.style[propertyName] = computedStyle[propertyName] ?? "";
1055
+ }
1056
+ mirror.style.whiteSpace = "pre-wrap";
1057
+ mirror.style.wordBreak = computedStyle.wordBreak;
1058
+ mirror.style.overflowWrap = computedStyle.overflowWrap;
1059
+ const preSelectionText = textarea.value.slice(0, safeSelectionStart);
1060
+ mirror.textContent = preSelectionText.length > 0 ? preSelectionText : "";
1061
+ if (preSelectionText.endsWith("\n")) {
1062
+ mirror.append(document.createTextNode("\u200B"));
1063
+ }
1064
+ marker.textContent = "\u200B";
1065
+ mirror.append(marker);
1066
+ document.body.append(mirror);
1067
+ try {
1068
+ const markerRect = marker.getBoundingClientRect();
1069
+ const lineHeight = resolveLineHeight(computedStyle);
1070
+ return {
1071
+ x: markerRect.left - textarea.scrollLeft,
1072
+ y: markerRect.top - textarea.scrollTop,
1073
+ lineHeight
1074
+ };
1075
+ } finally {
1076
+ mirror.remove();
1077
+ }
1078
+ }
1079
+
1080
+ // src/editor/RichTextAtTextarea.tsx
1081
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1082
+ function RichTextAtTextarea({
1083
+ value,
1084
+ onChange,
1085
+ providers = [],
1086
+ placeholder,
1087
+ disabled = false,
1088
+ className,
1089
+ textareaClassName,
1090
+ rows,
1091
+ minQueryLength = 0,
1092
+ maxResults = 8,
1093
+ removeDecorationAriaLabel,
1094
+ i18n,
1095
+ textOverrides,
1096
+ overlay
1097
+ }) {
1098
+ const menuOffset = 6;
1099
+ const text = resolveRichTextAtText(
1100
+ textOverrides,
1101
+ removeDecorationAriaLabel,
1102
+ i18n
1103
+ );
1104
+ const textareaRef = useRef2(null);
1105
+ const [selectionStart, setSelectionStart] = useState3(0);
1106
+ const [matches, setMatches] = useState3([]);
1107
+ const [activeIndex, setActiveIndex] = useState3(0);
1108
+ const [isLoading, setIsLoading] = useState3(false);
1109
+ const [isFocused, setIsFocused] = useState3(false);
1110
+ const [menuPoint, setMenuPoint] = useState3(
1111
+ null
1112
+ );
1113
+ const [scrollPosition, setScrollPosition] = useState3({ left: 0, top: 0 });
1114
+ const [textareaPresentationStyle, setTextareaPresentationStyle] = useState3(null);
1115
+ const pendingSelectionRef = useRef2(null);
1116
+ const registry = useMemo2(
1117
+ () => createRichTextAtRegistry(providers),
1118
+ [providers]
1119
+ );
1120
+ const decorationSegments = useMemo2(
1121
+ () => buildRichTextTextareaDecorationSegments(value),
1122
+ [value]
1123
+ );
1124
+ const hasDecorations = hasRichTextTextareaDecorations(decorationSegments);
1125
+ const query = isFocused ? findRichTextAtQuery(value, selectionStart) : null;
1126
+ const shouldQuery = query !== null && query.keyword.length >= minQueryLength && providers.length > 0;
1127
+ const isMenuOpen = isFocused && shouldQuery && (isLoading || matches.length > 0);
1128
+ useEffect3(() => {
1129
+ const nextSelection = pendingSelectionRef.current;
1130
+ if (nextSelection === null || !textareaRef.current) {
1131
+ return;
1132
+ }
1133
+ textareaRef.current.setSelectionRange(nextSelection, nextSelection);
1134
+ pendingSelectionRef.current = null;
1135
+ }, [value]);
1136
+ useEffect3(() => {
1137
+ if (!shouldQuery || !query) {
1138
+ setMatches([]);
1139
+ setActiveIndex(0);
1140
+ setIsLoading(false);
1141
+ return;
1142
+ }
1143
+ const abortController = new AbortController();
1144
+ setIsLoading(true);
1145
+ void queryRichTextAtMatches(registry, {
1146
+ abortSignal: abortController.signal,
1147
+ keyword: query.keyword,
1148
+ maxResults,
1149
+ context: {
1150
+ documentText: value,
1151
+ blockText: value
1152
+ }
1153
+ }).then((nextMatches) => {
1154
+ if (abortController.signal.aborted) {
1155
+ return;
1156
+ }
1157
+ setMatches(nextMatches);
1158
+ setActiveIndex(
1159
+ (current) => nextMatches.length === 0 ? 0 : Math.max(0, Math.min(current, nextMatches.length - 1))
1160
+ );
1161
+ }).finally(() => {
1162
+ if (!abortController.signal.aborted) {
1163
+ setIsLoading(false);
1164
+ }
1165
+ });
1166
+ return () => {
1167
+ abortController.abort();
1168
+ };
1169
+ }, [maxResults, query, registry, shouldQuery, value]);
1170
+ useLayoutEffect2(() => {
1171
+ if (!textareaRef.current || !hasDecorations) {
1172
+ setTextareaPresentationStyle(null);
1173
+ return;
1174
+ }
1175
+ const textarea = textareaRef.current;
1176
+ setTextareaPresentationStyle(getTextareaPresentationStyle(textarea));
1177
+ setScrollPosition({
1178
+ left: textarea.scrollLeft,
1179
+ top: textarea.scrollTop
1180
+ });
1181
+ }, [hasDecorations, textareaClassName, value]);
1182
+ useLayoutEffect2(() => {
1183
+ if (!isMenuOpen || !query || !textareaRef.current) {
1184
+ setMenuPoint(null);
1185
+ return;
1186
+ }
1187
+ const caretPoint = getTextareaCaretViewportPoint(
1188
+ textareaRef.current,
1189
+ query.to
1190
+ );
1191
+ const textareaRect = textareaRef.current.getBoundingClientRect();
1192
+ if (!caretPoint) {
1193
+ setMenuPoint({
1194
+ x: textareaRect.left + 12,
1195
+ y: textareaRect.bottom + menuOffset
1196
+ });
1197
+ return;
1198
+ }
1199
+ setMenuPoint({
1200
+ x: caretPoint.x,
1201
+ y: caretPoint.y + caretPoint.lineHeight + menuOffset
1202
+ });
1203
+ }, [isMenuOpen, menuOffset, query, selectionStart, value]);
1204
+ useEffect3(() => {
1205
+ if (!isMenuOpen || !query || !textareaRef.current) {
1206
+ return;
1207
+ }
1208
+ const textarea = textareaRef.current;
1209
+ const updateMenuPoint = () => {
1210
+ const caretPoint = getTextareaCaretViewportPoint(textarea, query.to);
1211
+ const textareaRect = textarea.getBoundingClientRect();
1212
+ setMenuPoint(
1213
+ caretPoint ? {
1214
+ x: caretPoint.x,
1215
+ y: caretPoint.y + caretPoint.lineHeight + menuOffset
1216
+ } : {
1217
+ x: textareaRect.left + 12,
1218
+ y: textareaRect.bottom + menuOffset
1219
+ }
1220
+ );
1221
+ };
1222
+ const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
1223
+ updateMenuPoint();
1224
+ });
1225
+ resizeObserver?.observe(textarea);
1226
+ textarea.addEventListener("scroll", updateMenuPoint, { passive: true });
1227
+ window.addEventListener("resize", updateMenuPoint);
1228
+ window.addEventListener("scroll", updateMenuPoint, {
1229
+ capture: true,
1230
+ passive: true
1231
+ });
1232
+ return () => {
1233
+ resizeObserver?.disconnect();
1234
+ textarea.removeEventListener("scroll", updateMenuPoint);
1235
+ window.removeEventListener("resize", updateMenuPoint);
1236
+ window.removeEventListener("scroll", updateMenuPoint, true);
1237
+ };
1238
+ }, [isMenuOpen, menuOffset, query]);
1239
+ const closeMenu = () => {
1240
+ setMatches([]);
1241
+ setActiveIndex(0);
1242
+ setIsLoading(false);
1243
+ setMenuPoint(null);
1244
+ };
1245
+ const setSelection = (nextSelection) => {
1246
+ pendingSelectionRef.current = nextSelection;
1247
+ setSelectionStart(nextSelection);
1248
+ if (textareaRef.current) {
1249
+ textareaRef.current.focus();
1250
+ textareaRef.current.setSelectionRange(nextSelection, nextSelection);
1251
+ pendingSelectionRef.current = null;
1252
+ }
1253
+ };
1254
+ const handleClickDecoration = (segment, event) => {
1255
+ event.preventDefault();
1256
+ const bounds = event.currentTarget.getBoundingClientRect();
1257
+ const nextSelection = event.clientX < bounds.left + bounds.width / 2 ? segment.from : segment.to;
1258
+ setSelection(nextSelection);
1259
+ };
1260
+ const handleRemoveDecoration = (segment) => {
1261
+ const nextValue = `${value.slice(0, segment.from)}${value.slice(segment.to)}`;
1262
+ const nextSelection = Math.min(segment.from, nextValue.length);
1263
+ pendingSelectionRef.current = nextSelection;
1264
+ onChange(nextValue);
1265
+ setSelectionStart(nextSelection);
1266
+ window.requestAnimationFrame(() => {
1267
+ if (!textareaRef.current) {
1268
+ return;
1269
+ }
1270
+ textareaRef.current.focus();
1271
+ textareaRef.current.setSelectionRange(nextSelection, nextSelection);
1272
+ pendingSelectionRef.current = null;
1273
+ });
1274
+ };
1275
+ const applyMatch = (match) => {
1276
+ const currentQuery = findRichTextAtQuery(
1277
+ value,
1278
+ textareaRef.current?.selectionStart ?? selectionStart
1279
+ );
1280
+ if (!currentQuery) {
1281
+ return;
1282
+ }
1283
+ const insertedValue = renderRichTextAtInsertResult(
1284
+ match.providerId,
1285
+ match.insertResult
1286
+ );
1287
+ const nextValue = `${value.slice(0, currentQuery.from)}${insertedValue}${value.slice(currentQuery.to)}`;
1288
+ const nextSelection = currentQuery.from + insertedValue.length;
1289
+ pendingSelectionRef.current = nextSelection;
1290
+ onChange(nextValue);
1291
+ closeMenu();
1292
+ };
1293
+ const handleKeyDown = (event) => {
1294
+ if (isRichTextImeComposing(event)) {
1295
+ return;
1296
+ }
1297
+ if (matches.length === 0) {
1298
+ return;
1299
+ }
1300
+ if (event.key === "ArrowDown") {
1301
+ event.preventDefault();
1302
+ setActiveIndex((current) => (current + 1) % matches.length);
1303
+ return;
1304
+ }
1305
+ if (event.key === "ArrowUp") {
1306
+ event.preventDefault();
1307
+ setActiveIndex(
1308
+ (current) => (current - 1 + matches.length) % matches.length
1309
+ );
1310
+ return;
1311
+ }
1312
+ if (event.key === "Enter" || event.key === "Tab") {
1313
+ const match = matches[activeIndex];
1314
+ if (!match) {
1315
+ return;
1316
+ }
1317
+ event.preventDefault();
1318
+ applyMatch(match);
1319
+ return;
1320
+ }
1321
+ if (event.key === "Escape") {
1322
+ event.preventDefault();
1323
+ closeMenu();
1324
+ }
1325
+ };
1326
+ return /* @__PURE__ */ jsxs5("div", { className: cn3("relative min-w-0 w-full", className), children: [
1327
+ hasDecorations && textareaPresentationStyle ? /* @__PURE__ */ jsx5(
1328
+ RichTextTextareaDecoratedContent,
1329
+ {
1330
+ onClickSegment: handleClickDecoration,
1331
+ onRemoveSegment: handleRemoveDecoration,
1332
+ removeActionAriaLabel: text.removeReferenceActionLabel,
1333
+ scrollLeft: scrollPosition.left,
1334
+ scrollTop: scrollPosition.top,
1335
+ segments: decorationSegments,
1336
+ textareaStyle: textareaPresentationStyle
1337
+ }
1338
+ ) : null,
1339
+ /* @__PURE__ */ jsx5(
1340
+ "textarea",
1341
+ {
1342
+ ref: textareaRef,
1343
+ className: textareaClassName,
1344
+ disabled,
1345
+ placeholder,
1346
+ rows,
1347
+ style: hasDecorations ? {
1348
+ WebkitTextFillColor: "transparent",
1349
+ caretColor: "var(--text-primary)",
1350
+ color: "transparent",
1351
+ position: "relative",
1352
+ zIndex: 0
1353
+ } : void 0,
1354
+ value,
1355
+ onBlur: () => {
1356
+ window.setTimeout(() => {
1357
+ setIsFocused(false);
1358
+ closeMenu();
1359
+ }, 100);
1360
+ },
1361
+ onChange: (event) => {
1362
+ const rawSelectionStart = event.target.selectionStart ?? 0;
1363
+ const nextSelection = resolveRichTextTextareaSelectionBoundary(
1364
+ decorationSegments,
1365
+ rawSelectionStart
1366
+ ) ?? rawSelectionStart;
1367
+ if (nextSelection !== rawSelectionStart) {
1368
+ event.target.setSelectionRange(nextSelection, nextSelection);
1369
+ }
1370
+ setSelectionStart(nextSelection);
1371
+ onChange(event.target.value);
1372
+ },
1373
+ onFocus: (event) => {
1374
+ setIsFocused(true);
1375
+ const rawSelectionStart = event.target.selectionStart ?? 0;
1376
+ const nextSelection = resolveRichTextTextareaSelectionBoundary(
1377
+ decorationSegments,
1378
+ rawSelectionStart
1379
+ ) ?? rawSelectionStart;
1380
+ if (nextSelection !== rawSelectionStart) {
1381
+ event.target.setSelectionRange(nextSelection, nextSelection);
1382
+ }
1383
+ setSelectionStart(nextSelection);
1384
+ setScrollPosition({
1385
+ left: event.target.scrollLeft,
1386
+ top: event.target.scrollTop
1387
+ });
1388
+ },
1389
+ onKeyDown: handleKeyDown,
1390
+ onScroll: (event) => {
1391
+ setScrollPosition({
1392
+ left: event.currentTarget.scrollLeft,
1393
+ top: event.currentTarget.scrollTop
1394
+ });
1395
+ },
1396
+ onSelect: (event) => {
1397
+ const rawSelectionStart = event.currentTarget.selectionStart ?? 0;
1398
+ const nextSelection = resolveRichTextTextareaSelectionBoundary(
1399
+ decorationSegments,
1400
+ rawSelectionStart
1401
+ ) ?? rawSelectionStart;
1402
+ if (nextSelection !== rawSelectionStart) {
1403
+ event.currentTarget.setSelectionRange(nextSelection, nextSelection);
1404
+ }
1405
+ setSelectionStart(nextSelection);
1406
+ }
1407
+ }
1408
+ ),
1409
+ overlay,
1410
+ isMenuOpen && menuPoint ? /* @__PURE__ */ jsx5(
1411
+ ViewportMenuSurface2,
1412
+ {
1413
+ open: true,
1414
+ className: "nextop-rich-text-at-menu max-h-64 w-[min(28rem,calc(100vw-24px))] overflow-y-auto p-1",
1415
+ dismissIgnoreRefs: [textareaRef],
1416
+ placement: {
1417
+ type: "point",
1418
+ point: menuPoint,
1419
+ alignX: "start",
1420
+ alignY: "start",
1421
+ estimatedSize: {
1422
+ width: 360,
1423
+ height: 256
1424
+ }
1425
+ },
1426
+ children: matches.length > 0 ? matches.map((match, index) => /* @__PURE__ */ jsxs5(
1427
+ "button",
1428
+ {
1429
+ "aria-selected": index === activeIndex,
1430
+ className: cn3(
1431
+ "flex w-full cursor-pointer flex-col items-start gap-0.5 rounded-md px-2.5 py-2 text-left outline-none transition-colors",
1432
+ index === activeIndex ? "bg-transparency-block text-[var(--text-primary)]" : "text-[var(--text-primary)] hover:bg-transparency-block"
1433
+ ),
1434
+ type: "button",
1435
+ onMouseDown: (event) => {
1436
+ event.preventDefault();
1437
+ applyMatch(match);
1438
+ },
1439
+ children: [
1440
+ /* @__PURE__ */ jsx5("div", { className: "text-sm leading-5 font-medium", children: match.label }),
1441
+ match.subtitle ? /* @__PURE__ */ jsx5("div", { className: "text-xs leading-4 text-[var(--text-secondary)]", children: match.subtitle }) : null
1442
+ ]
1443
+ },
1444
+ `${match.providerId}:${match.key}`
1445
+ )) : /* @__PURE__ */ jsx5("div", { className: "px-3 py-2 text-xs leading-4 text-[var(--text-secondary)]", children: isLoading ? text.loadingLabel : text.noMatchesLabel })
1446
+ }
1447
+ ) : null
1448
+ ] });
1449
+ }
1450
+
1451
+ // src/editor/RichTextMentionReadonly.tsx
1452
+ import { jsx as jsx6 } from "react/jsx-runtime";
1453
+ var baseStyle = {
1454
+ alignItems: "center",
1455
+ border: "1px solid transparent",
1456
+ borderRadius: "999px",
1457
+ display: "inline-flex",
1458
+ fontSize: "0.95em",
1459
+ gap: "0.25rem",
1460
+ lineHeight: 1.4,
1461
+ maxWidth: "100%",
1462
+ padding: "0.05rem 0.45rem",
1463
+ textDecoration: "none",
1464
+ verticalAlign: "baseline",
1465
+ whiteSpace: "nowrap"
1466
+ };
1467
+ var stateStyles = {
1468
+ active: {
1469
+ background: "var(--nextop-rich-text-mention-active-bg, color-mix(in srgb, currentColor 12%, transparent))",
1470
+ color: "var(--nextop-rich-text-mention-active-fg, inherit)",
1471
+ cursor: "pointer"
1472
+ },
1473
+ missing: {
1474
+ background: "var(--nextop-rich-text-mention-missing-bg, color-mix(in srgb, currentColor 6%, transparent))",
1475
+ color: "var(--nextop-rich-text-mention-missing-fg, color-mix(in srgb, currentColor 48%, transparent))",
1476
+ cursor: "default",
1477
+ textDecoration: "line-through"
1478
+ },
1479
+ disabled: {
1480
+ background: "var(--nextop-rich-text-mention-disabled-bg, color-mix(in srgb, currentColor 6%, transparent))",
1481
+ color: "var(--nextop-rich-text-mention-disabled-fg, color-mix(in srgb, currentColor 58%, transparent))",
1482
+ cursor: "not-allowed",
1483
+ opacity: 0.88
1484
+ },
1485
+ loading: {
1486
+ background: "var(--nextop-rich-text-mention-loading-bg, color-mix(in srgb, currentColor 8%, transparent))",
1487
+ color: "var(--nextop-rich-text-mention-loading-fg, color-mix(in srgb, currentColor 82%, transparent))",
1488
+ cursor: "progress",
1489
+ opacity: 0.92
1490
+ }
1491
+ };
1492
+ function joinClassNames(...parts) {
1493
+ const value = parts.filter(Boolean).join(" ").trim();
1494
+ return value || void 0;
1495
+ }
1496
+ function RichTextMentionReadonly({
1497
+ mention,
1498
+ resolved,
1499
+ className,
1500
+ title,
1501
+ onClick,
1502
+ renderLabel
1503
+ }) {
1504
+ const view = resolveRichTextMentionView(mention, resolved);
1505
+ const payload = {
1506
+ mention,
1507
+ resolved: view
1508
+ };
1509
+ const label = renderLabel?.(payload) ?? getRichTextMentionDisplayText({
1510
+ ...mention,
1511
+ label: view.label
1512
+ });
1513
+ const elementTitle = title ?? view.tooltip;
1514
+ const handleClick = (event) => {
1515
+ if (!view.interactive) {
1516
+ event.preventDefault();
1517
+ return;
1518
+ }
1519
+ onClick?.(payload);
1520
+ };
1521
+ const sharedProps = {
1522
+ "aria-busy": view.state === "loading" || void 0,
1523
+ "aria-disabled": !view.interactive || void 0,
1524
+ className: joinClassNames(
1525
+ "nextop-rich-text-mention",
1526
+ `nextop-rich-text-mention--${view.state}`,
1527
+ className
1528
+ ),
1529
+ "data-plugin": mention.plugin,
1530
+ "data-state": view.state,
1531
+ style: {
1532
+ ...baseStyle,
1533
+ ...stateStyles[view.state]
1534
+ },
1535
+ title: elementTitle
1536
+ };
1537
+ if (view.interactive && view.href) {
1538
+ return /* @__PURE__ */ jsx6(
1539
+ "a",
1540
+ {
1541
+ ...sharedProps,
1542
+ href: view.href,
1543
+ onClick: (event) => {
1544
+ handleClick(event);
1545
+ },
1546
+ children: label
1547
+ }
1548
+ );
1549
+ }
1550
+ if (view.interactive && onClick) {
1551
+ return /* @__PURE__ */ jsx6(
1552
+ "button",
1553
+ {
1554
+ ...sharedProps,
1555
+ onClick: (event) => {
1556
+ handleClick(event);
1557
+ },
1558
+ style: {
1559
+ ...sharedProps.style,
1560
+ appearance: "none",
1561
+ font: "inherit"
1562
+ },
1563
+ type: "button",
1564
+ children: label
1565
+ }
1566
+ );
1567
+ }
1568
+ return /* @__PURE__ */ jsx6("span", { ...sharedProps, children: label });
1569
+ }
1570
+
1571
+ // src/editor/RichTextReadonlyContent.tsx
1572
+ import {
1573
+ MentionPill as MentionPill3,
1574
+ Tooltip as Tooltip2,
1575
+ TooltipContent as TooltipContent2,
1576
+ TooltipTrigger as TooltipTrigger2
1577
+ } from "@tutti-os/ui-system/components";
1578
+ import { cn as cn4 } from "@tutti-os/ui-system/utils";
1579
+
1580
+ // src/editor/richTextReadonlyContentModel.ts
1581
+ function buildRichTextReadonlyInlineSegments(content) {
1582
+ const segments = [];
1583
+ let cursor = 0;
1584
+ for (const match of findRichTextMarkdownLinks(content)) {
1585
+ if (match.index > cursor) {
1586
+ segments.push({
1587
+ text: content.slice(cursor, match.index),
1588
+ type: "text"
1589
+ });
1590
+ }
1591
+ segments.push({
1592
+ href: match.href,
1593
+ label: match.label || match.href,
1594
+ type: "link"
1595
+ });
1596
+ cursor = match.to;
1597
+ }
1598
+ if (cursor < content.length) {
1599
+ segments.push({
1600
+ text: content.slice(cursor),
1601
+ type: "text"
1602
+ });
1603
+ }
1604
+ return segments;
1605
+ }
1606
+
1607
+ // src/editor/RichTextReadonlyContent.tsx
1608
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1609
+ var externalHrefPattern = /^(?:[a-z]+:)?\/\//i;
1610
+ var richTextWorkspaceReferencePillClassName3 = "max-w-[18rem]";
1611
+ function RichTextReadonlyContent({
1612
+ value,
1613
+ className,
1614
+ paragraphClassName,
1615
+ onOpenWorkspaceReference
1616
+ }) {
1617
+ const normalizedValue = normalizeRichTextContent(value).trim();
1618
+ if (!normalizedValue) {
1619
+ return null;
1620
+ }
1621
+ const paragraphs = normalizedValue.split(/\n{2,}/).map((paragraph) => paragraph.trim()).filter(Boolean);
1622
+ return /* @__PURE__ */ jsx7("div", { className: cn4("space-y-4", className), children: paragraphs.map((paragraph, paragraphIndex) => /* @__PURE__ */ jsx7(
1623
+ "p",
1624
+ {
1625
+ className: cn4("whitespace-pre-wrap", paragraphClassName),
1626
+ children: renderReadonlyInlineMarkdown(paragraph, onOpenWorkspaceReference)
1627
+ },
1628
+ `${paragraphIndex}:${paragraph}`
1629
+ )) });
1630
+ }
1631
+ function renderReadonlyInlineMarkdown(content, onOpenWorkspaceReference) {
1632
+ const parts = [];
1633
+ const segments = buildRichTextReadonlyInlineSegments(content);
1634
+ segments.forEach((segment, index) => {
1635
+ if (segment.type === "text") {
1636
+ parts.push(/* @__PURE__ */ jsx7("span", { children: segment.text }, `text:${index}`));
1637
+ return;
1638
+ }
1639
+ parts.push(
1640
+ /* @__PURE__ */ jsx7(
1641
+ RichTextReadonlyInlineLink,
1642
+ {
1643
+ href: segment.href,
1644
+ label: segment.label,
1645
+ onOpenWorkspaceReference
1646
+ },
1647
+ `link:${index}:${segment.href}`
1648
+ )
1649
+ );
1650
+ });
1651
+ return parts;
1652
+ }
1653
+ function RichTextReadonlyInlineLink({
1654
+ href,
1655
+ label,
1656
+ onOpenWorkspaceReference
1657
+ }) {
1658
+ const trimmedHref = href.trim();
1659
+ const mention = parseRichTextMentionHref(trimmedHref, label);
1660
+ if (mention) {
1661
+ return /* @__PURE__ */ jsx7(RichTextMentionReadonly, { mention });
1662
+ }
1663
+ if (!trimmedHref) {
1664
+ return /* @__PURE__ */ jsx7("span", { children: label });
1665
+ }
1666
+ if (externalHrefPattern.test(trimmedHref) && !isRichTextMentionHref(trimmedHref)) {
1667
+ return /* @__PURE__ */ jsx7(
1668
+ "a",
1669
+ {
1670
+ className: "font-medium text-[var(--text-primary)] underline decoration-[var(--border-1)] underline-offset-4 hover:text-[var(--text-primary-hover)]",
1671
+ href: trimmedHref,
1672
+ rel: "noreferrer",
1673
+ target: "_blank",
1674
+ children: label
1675
+ }
1676
+ );
1677
+ }
1678
+ const kind = trimmedHref.endsWith("/") ? "folder" : "file";
1679
+ const path = normalizeRichTextLinkHref(trimmedHref, kind);
1680
+ return /* @__PURE__ */ jsx7(
1681
+ WorkspaceReferenceReadonly,
1682
+ {
1683
+ reference: {
1684
+ kind,
1685
+ label,
1686
+ path
1687
+ },
1688
+ onOpenWorkspaceReference
1689
+ }
1690
+ );
1691
+ }
1692
+ function WorkspaceReferenceReadonly({
1693
+ reference,
1694
+ onOpenWorkspaceReference
1695
+ }) {
1696
+ const presentation = getWorkspaceReferencePresentation(
1697
+ reference.label,
1698
+ reference.path
1699
+ );
1700
+ const content = /* @__PURE__ */ jsx7(
1701
+ MentionPill3,
1702
+ {
1703
+ className: richTextWorkspaceReferencePillClassName3,
1704
+ fileKind: reference.kind,
1705
+ kind: "file",
1706
+ label: presentation.displayLabel,
1707
+ removable: false
1708
+ }
1709
+ );
1710
+ return /* @__PURE__ */ jsxs6(Tooltip2, { children: [
1711
+ /* @__PURE__ */ jsx7(TooltipTrigger2, { asChild: true, children: onOpenWorkspaceReference ? /* @__PURE__ */ jsx7(
1712
+ "button",
1713
+ {
1714
+ className: "inline-flex max-w-full appearance-none items-baseline bg-transparent p-0 text-inherit",
1715
+ type: "button",
1716
+ onClick: () => {
1717
+ void onOpenWorkspaceReference(reference);
1718
+ },
1719
+ children: content
1720
+ }
1721
+ ) : /* @__PURE__ */ jsx7("span", { className: "inline-flex max-w-full align-baseline", children: content }) }),
1722
+ /* @__PURE__ */ jsx7(
1723
+ TooltipContent2,
1724
+ {
1725
+ className: "max-w-md whitespace-normal break-all",
1726
+ sideOffset: 8,
1727
+ children: presentation.fullPath
1728
+ }
1729
+ )
1730
+ ] });
1731
+ }
1732
+ export {
1733
+ RichTextAtEditor,
1734
+ RichTextAtTextarea,
1735
+ RichTextMentionReadonly,
1736
+ RichTextReadonlyContent
1737
+ };
1738
+ //# sourceMappingURL=index.js.map