@streamsai/ui 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/dist/components/ui/agent-avatar.d.ts +50 -0
- package/dist/components/ui/agent-avatar.js +38 -0
- package/dist/components/ui/alert-dialog.d.ts +12 -0
- package/dist/components/ui/alert-dialog.js +37 -0
- package/dist/components/ui/alert.d.ts +10 -0
- package/dist/components/ui/alert.js +28 -0
- package/dist/components/ui/assets/loader.json.js +1 -0
- package/dist/components/ui/avatar-frame.d.ts +11 -0
- package/dist/components/ui/avatar-frame.js +20 -0
- package/dist/components/ui/avatar.d.ts +23 -0
- package/dist/components/ui/avatar.js +54 -0
- package/dist/components/ui/badge.d.ts +7 -0
- package/dist/components/ui/badge.js +36 -0
- package/dist/components/ui/bar-list.d.ts +40 -0
- package/dist/components/ui/bar-list.js +37 -0
- package/dist/components/ui/breadcrumb.d.ts +8 -0
- package/dist/components/ui/breadcrumb.js +21 -0
- package/dist/components/ui/button-group.d.ts +10 -0
- package/dist/components/ui/button-group.js +41 -0
- package/dist/components/ui/button.d.ts +9 -0
- package/dist/components/ui/button.js +39 -0
- package/dist/components/ui/card.d.ts +9 -0
- package/dist/components/ui/card.js +25 -0
- package/dist/components/ui/checkbox.d.ts +3 -0
- package/dist/components/ui/checkbox.js +16 -0
- package/dist/components/ui/chip.d.ts +13 -0
- package/dist/components/ui/chip.js +22 -0
- package/dist/components/ui/clickable.d.ts +27 -0
- package/dist/components/ui/clickable.js +54 -0
- package/dist/components/ui/code-block.d.ts +68 -0
- package/dist/components/ui/code-block.js +112 -0
- package/dist/components/ui/code-view.d.ts +21 -0
- package/dist/components/ui/code-view.js +20 -0
- package/dist/components/ui/collapsible.d.ts +6 -0
- package/dist/components/ui/collapsible.js +18 -0
- package/dist/components/ui/combobox.d.ts +14 -0
- package/dist/components/ui/combobox.js +37 -0
- package/dist/components/ui/command.d.ts +15 -0
- package/dist/components/ui/command.js +34 -0
- package/dist/components/ui/context-menu.d.ts +21 -0
- package/dist/components/ui/context-menu.js +60 -0
- package/dist/components/ui/counter.d.ts +11 -0
- package/dist/components/ui/counter.js +20 -0
- package/dist/components/ui/dialog.d.ts +17 -0
- package/dist/components/ui/dialog.js +41 -0
- package/dist/components/ui/disabled-reason.d.ts +22 -0
- package/dist/components/ui/disabled-reason.js +16 -0
- package/dist/components/ui/dropdown-menu.d.ts +33 -0
- package/dist/components/ui/dropdown-menu.js +58 -0
- package/dist/components/ui/editable-text.d.ts +95 -0
- package/dist/components/ui/editable-text.js +191 -0
- package/dist/components/ui/heading.d.ts +24 -0
- package/dist/components/ui/heading.js +92 -0
- package/dist/components/ui/hover-card.d.ts +9 -0
- package/dist/components/ui/hover-card.js +16 -0
- package/dist/components/ui/icon-button.d.ts +23 -0
- package/dist/components/ui/icon-button.js +32 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/input.js +8 -0
- package/dist/components/ui/kbd.d.ts +12 -0
- package/dist/components/ui/kbd.js +12 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +7 -0
- package/dist/components/ui/loader.d.ts +19 -0
- package/dist/components/ui/loader.js +157 -0
- package/dist/components/ui/message-toolbar.d.ts +20 -0
- package/dist/components/ui/message-toolbar.js +17 -0
- package/dist/components/ui/modal.d.ts +30 -0
- package/dist/components/ui/modal.js +41 -0
- package/dist/components/ui/nav-item.d.ts +25 -0
- package/dist/components/ui/nav-item.js +66 -0
- package/dist/components/ui/panel-section.d.ts +22 -0
- package/dist/components/ui/panel-section.js +26 -0
- package/dist/components/ui/popover.d.ts +14 -0
- package/dist/components/ui/popover.js +30 -0
- package/dist/components/ui/progress.d.ts +5 -0
- package/dist/components/ui/progress.js +16 -0
- package/dist/components/ui/provider-badge.d.ts +22 -0
- package/dist/components/ui/provider-badge.js +38 -0
- package/dist/components/ui/radio-group.d.ts +5 -0
- package/dist/components/ui/radio-group.js +21 -0
- package/dist/components/ui/reply-summary.d.ts +16 -0
- package/dist/components/ui/reply-summary.js +8 -0
- package/dist/components/ui/resizable.d.ts +8 -0
- package/dist/components/ui/resizable.js +15 -0
- package/dist/components/ui/rich-text-editor.d.ts +447 -0
- package/dist/components/ui/rich-text-editor.js +2419 -0
- package/dist/components/ui/running-indicator.d.ts +17 -0
- package/dist/components/ui/running-indicator.js +34 -0
- package/dist/components/ui/sandboxed-frame.d.ts +29 -0
- package/dist/components/ui/sandboxed-frame.js +77 -0
- package/dist/components/ui/scroll-area.d.ts +14 -0
- package/dist/components/ui/scroll-area.js +17 -0
- package/dist/components/ui/section-header.d.ts +12 -0
- package/dist/components/ui/section-header.js +12 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.js +40 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +13 -0
- package/dist/components/ui/sheet.d.ts +23 -0
- package/dist/components/ui/sheet.js +48 -0
- package/dist/components/ui/shimmer-text.d.ts +26 -0
- package/dist/components/ui/shimmer-text.js +32 -0
- package/dist/components/ui/sidebar.d.ts +63 -0
- package/dist/components/ui/sidebar.js +283 -0
- package/dist/components/ui/skeleton.d.ts +14 -0
- package/dist/components/ui/skeleton.js +12 -0
- package/dist/components/ui/sonner.d.ts +4 -0
- package/dist/components/ui/sonner.js +14 -0
- package/dist/components/ui/spark-bars.d.ts +29 -0
- package/dist/components/ui/spark-bars.js +24 -0
- package/dist/components/ui/spinner.d.ts +16 -0
- package/dist/components/ui/spinner.js +26 -0
- package/dist/components/ui/stacked-bar.d.ts +37 -0
- package/dist/components/ui/stacked-bar.js +56 -0
- package/dist/components/ui/status-dot.d.ts +16 -0
- package/dist/components/ui/status-dot.js +32 -0
- package/dist/components/ui/switch.d.ts +5 -0
- package/dist/components/ui/switch.js +13 -0
- package/dist/components/ui/tabs.d.ts +14 -0
- package/dist/components/ui/tabs.js +55 -0
- package/dist/components/ui/text.d.ts +27 -0
- package/dist/components/ui/text.js +100 -0
- package/dist/components/ui/textarea.d.ts +3 -0
- package/dist/components/ui/textarea.js +7 -0
- package/dist/components/ui/theme-toggle.d.ts +21 -0
- package/dist/components/ui/theme-toggle.js +17 -0
- package/dist/components/ui/toggle-group.d.ts +6 -0
- package/dist/components/ui/toggle-group.js +12 -0
- package/dist/components/ui/tooltip.d.ts +11 -0
- package/dist/components/ui/tooltip.js +21 -0
- package/dist/components/ui/trace.d.ts +24 -0
- package/dist/components/ui/trace.js +57 -0
- package/dist/components/ui/trend-line.d.ts +93 -0
- package/dist/components/ui/trend-line.js +215 -0
- package/dist/components/ui/truncate.d.ts +30 -0
- package/dist/components/ui/truncate.js +54 -0
- package/dist/components/ui/viewer-shell.d.ts +55 -0
- package/dist/components/ui/viewer-shell.js +15 -0
- package/dist/components/ui/virtual-list.d.ts +91 -0
- package/dist/components/ui/virtual-list.js +406 -0
- package/dist/components/ui/visually-hidden.d.ts +15 -0
- package/dist/components/ui/visually-hidden.js +19 -0
- package/dist/direction/index.d.ts +19 -0
- package/dist/direction/index.js +32 -0
- package/dist/hooks/use-code-highlighting.d.ts +22 -0
- package/dist/hooks/use-code-highlighting.js +47 -0
- package/dist/hooks/use-is-truncated.d.ts +7 -0
- package/dist/hooks/use-is-truncated.js +23 -0
- package/dist/hooks/use-mobile.d.ts +2 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/icons/custom.d.ts +29 -0
- package/dist/icons/custom.js +48 -0
- package/dist/icons/index.d.ts +49 -0
- package/dist/icons/index.js +50 -0
- package/dist/icons/provider-logos.d.ts +17 -0
- package/dist/icons/provider-logos.js +28 -0
- package/dist/icons/providers.d.ts +9 -0
- package/dist/icons/providers.js +51 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +124 -0
- package/dist/lib/code-highlighter.d.ts +9 -0
- package/dist/lib/code-highlighter.js +65 -0
- package/dist/lib/code-language.d.ts +13 -0
- package/dist/lib/code-language.js +103 -0
- package/dist/lib/code-tokens.d.ts +9 -0
- package/dist/lib/code-tokens.js +27 -0
- package/dist/lib/copy-text.d.ts +6 -0
- package/dist/lib/copy-text.js +46 -0
- package/dist/lib/home-tile.d.ts +12 -0
- package/dist/lib/home-tile.js +45 -0
- package/dist/lib/reveal-mask.d.ts +9 -0
- package/dist/lib/reveal-mask.js +16 -0
- package/dist/lib/text-direction.d.ts +28 -0
- package/dist/lib/text-direction.js +119 -0
- package/dist/lib/utils.d.ts +11 -0
- package/dist/lib/utils.js +30 -0
- package/dist/motion/components.d.ts +184 -0
- package/dist/motion/components.js +211 -0
- package/dist/motion/config.d.ts +849 -0
- package/dist/motion/config.js +429 -0
- package/dist/motion/index.d.ts +8 -0
- package/dist/motion/index.js +8 -0
- package/dist/motion/modal-presence.d.ts +5 -0
- package/dist/motion/modal-presence.js +27 -0
- package/dist/motion/popup.d.ts +77 -0
- package/dist/motion/popup.js +103 -0
- package/dist/platform/index.d.ts +29 -0
- package/dist/platform/index.js +63 -0
- package/dist/styles/base.css +319 -0
- package/dist/styles/fonts/Figtree-Variable.woff2 +0 -0
- package/dist/styles/fonts/OFL.txt +93 -0
- package/dist/styles/fonts/Poppins-Medium.woff2 +0 -0
- package/dist/styles/fonts/Poppins-Regular.woff2 +0 -0
- package/dist/styles/fonts/Poppins-SemiBold.woff2 +0 -0
- package/dist/styles/fonts.css +28 -0
- package/dist/styles/globals.css +8 -0
- package/dist/styles/mcp-host.css +76 -0
- package/dist/styles/tokens.css +387 -0
- package/dist/styles/tokens.d.mts +57 -0
- package/dist/styles/tokens.mjs +59 -0
- package/dist/theme/index.d.ts +28 -0
- package/dist/theme/index.js +65 -0
- package/package.json +136 -3
|
@@ -0,0 +1,2419 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import * as ReactDOM from 'react-dom';
|
|
4
|
+
import { $createParagraphNode, $createTextNode, $findMatchingParent, $getNodeByKey, $getRoot, $getSelection, $insertNodes, $isElementNode, $isLineBreakNode, $isParagraphNode, $isRangeSelection, $isRootNode, $isTextNode, $nodesOfType, $setSelection, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, DROP_COMMAND, FORMAT_TEXT_COMMAND, HISTORIC_TAG, INSERT_PARAGRAPH_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND, PASTE_TAG, stopLexicalPropagation, SELECTION_CHANGE_COMMAND, TextNode, createCommand, DecoratorNode, } from 'lexical';
|
|
5
|
+
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
|
6
|
+
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
|
7
|
+
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
|
8
|
+
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
|
9
|
+
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
|
10
|
+
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
|
|
11
|
+
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
|
12
|
+
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
|
|
13
|
+
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
|
|
14
|
+
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
|
|
15
|
+
import { LexicalTypeaheadMenuPlugin, MenuOption, useBasicTypeaheadTriggerMatch, } from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
|
16
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
17
|
+
import { $createHeadingNode, $isHeadingNode, HeadingNode, QuoteNode } from '@lexical/rich-text';
|
|
18
|
+
import { $insertNodeToNearestRoot } from '@lexical/utils';
|
|
19
|
+
import { $isListItemNode, $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListNode, ListItemNode, REMOVE_LIST_COMMAND, } from '@lexical/list';
|
|
20
|
+
import { $createLinkNode, $isLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
|
|
21
|
+
import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code';
|
|
22
|
+
import { BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, BOLD_UNDERSCORE, CODE, INLINE_CODE, ITALIC_STAR, ITALIC_UNDERSCORE, LINK, ORDERED_LIST, STRIKETHROUGH, UNORDERED_LIST, } from '@lexical/markdown';
|
|
23
|
+
import { cn } from '../../lib/utils.js';
|
|
24
|
+
import { Collapse, motion, useReducedMotion } from '../../motion/index.js';
|
|
25
|
+
import { loops } from '../../motion/config.js';
|
|
26
|
+
import { detectTextDirection, hasStrongCharacter } from '../../lib/text-direction.js';
|
|
27
|
+
import { AgentAvatar } from './agent-avatar.js';
|
|
28
|
+
import { ProviderBadge } from './provider-badge.js';
|
|
29
|
+
import { Avatar, AvatarFallback, AvatarImage } from './avatar.js';
|
|
30
|
+
import { Button } from './button.js';
|
|
31
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from './dialog.js';
|
|
32
|
+
import { Input } from './input.js';
|
|
33
|
+
import { Label } from './label.js';
|
|
34
|
+
import { Separator } from './separator.js';
|
|
35
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip.js';
|
|
36
|
+
import { Bold, Bot, Code, Image as ImageIcon, Italic, Link as LinkIcon, List, ListOrdered, Lock, Strikethrough, Trash, TriangleAlert, Users, } from '../../icons/index.js';
|
|
37
|
+
export const isAllMentionCandidate = (candidate) => candidate.kind === 'all';
|
|
38
|
+
/** Default page size for the mention picker (first paint + each scroll fetch). */
|
|
39
|
+
export const MENTION_PAGE_SIZE = 10;
|
|
40
|
+
/** Default page size for the emoji shortcode typeahead. */
|
|
41
|
+
export const EMOJI_TYPEAHEAD_LIMIT = 24;
|
|
42
|
+
/** Default page size for the stream chip typeahead. */
|
|
43
|
+
export const STREAM_TYPEAHEAD_LIMIT = 10;
|
|
44
|
+
/**
|
|
45
|
+
* Punctuation that ends a `:` emoji query. Default Lexical punctuation includes `_`, `-` and
|
|
46
|
+
* `+`, which would truncate shortcodes like `thumbsup`, `t-rex` and `+1`. Closing `:` stays so
|
|
47
|
+
* typing `:smile:` closes the menu (and the shortcode-replace plugin can expand it).
|
|
48
|
+
*/
|
|
49
|
+
const EMOJI_TYPEAHEAD_PUNCTUATION = '\\.,\\*\\?\\$\\@\\|#{}\\(\\)\\^\\[\\]\\\\/!%\'"~=<>:;';
|
|
50
|
+
const mentionInitials = (name) => name
|
|
51
|
+
.split(/\s+/)
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.slice(0, 2)
|
|
54
|
+
.map((part) => part[0]?.toUpperCase() ?? '')
|
|
55
|
+
.join('') || '?';
|
|
56
|
+
/** Custom node that round-trips with contracts/chat/adapters/lexical.ts */
|
|
57
|
+
export class MentionNode extends DecoratorNode {
|
|
58
|
+
__userId;
|
|
59
|
+
__displayName;
|
|
60
|
+
__all;
|
|
61
|
+
static getType() {
|
|
62
|
+
return 'mention';
|
|
63
|
+
}
|
|
64
|
+
static clone(node) {
|
|
65
|
+
return new MentionNode({ userId: node.__userId, displayName: node.__displayName, all: node.__all }, node.__key);
|
|
66
|
+
}
|
|
67
|
+
constructor(payload, key) {
|
|
68
|
+
super(key);
|
|
69
|
+
this.__userId = payload.userId;
|
|
70
|
+
this.__displayName = payload.displayName;
|
|
71
|
+
this.__all = payload.all;
|
|
72
|
+
}
|
|
73
|
+
createDOM(_config) {
|
|
74
|
+
const span = document.createElement('span');
|
|
75
|
+
span.className = 'mention';
|
|
76
|
+
span.dir = 'auto';
|
|
77
|
+
return span;
|
|
78
|
+
}
|
|
79
|
+
updateDOM() {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
static importJSON(serialized) {
|
|
84
|
+
return new MentionNode({
|
|
85
|
+
userId: serialized.userId,
|
|
86
|
+
displayName: serialized.displayName,
|
|
87
|
+
all: serialized.all,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
exportJSON() {
|
|
91
|
+
return {
|
|
92
|
+
type: 'mention',
|
|
93
|
+
version: 1,
|
|
94
|
+
userId: this.__userId,
|
|
95
|
+
displayName: this.__displayName,
|
|
96
|
+
all: this.__all,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
decorate() {
|
|
100
|
+
const label = this.__all ? '@all' : `@${this.__displayName ?? this.__userId ?? 'unknown'}`;
|
|
101
|
+
return (_jsx("span", { dir: "auto", className: "inline-flex items-center rounded bg-primary/10 px-1 text-primary", "data-mention": true, children: label }));
|
|
102
|
+
}
|
|
103
|
+
isInline() {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export const $isMentionNode = (node) => node instanceof MentionNode;
|
|
108
|
+
/** Custom node that round-trips with contracts/chat/adapters/lexical.ts */
|
|
109
|
+
export class CycleTriggerNode extends DecoratorNode {
|
|
110
|
+
__triggerName;
|
|
111
|
+
static getType() {
|
|
112
|
+
return 'cycle-trigger';
|
|
113
|
+
}
|
|
114
|
+
static clone(node) {
|
|
115
|
+
return new CycleTriggerNode({ triggerName: node.__triggerName }, node.__key);
|
|
116
|
+
}
|
|
117
|
+
constructor(payload, key) {
|
|
118
|
+
super(key);
|
|
119
|
+
this.__triggerName = payload.triggerName;
|
|
120
|
+
}
|
|
121
|
+
createDOM(_config) {
|
|
122
|
+
const span = document.createElement('span');
|
|
123
|
+
span.className = 'cycle-trigger';
|
|
124
|
+
span.dir = 'auto';
|
|
125
|
+
return span;
|
|
126
|
+
}
|
|
127
|
+
updateDOM() {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
131
|
+
static importJSON(serialized) {
|
|
132
|
+
return new CycleTriggerNode({ triggerName: serialized.triggerName });
|
|
133
|
+
}
|
|
134
|
+
exportJSON() {
|
|
135
|
+
return {
|
|
136
|
+
type: 'cycle-trigger',
|
|
137
|
+
version: 1,
|
|
138
|
+
triggerName: this.__triggerName,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
decorate() {
|
|
142
|
+
return _jsx(CycleTriggerView, { triggerName: this.__triggerName });
|
|
143
|
+
}
|
|
144
|
+
isInline() {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Host answer for a trigger chip: why it cannot run, or null. RichTextEditor provides it. */
|
|
149
|
+
const CycleTriggerIssueCtx = React.createContext(null);
|
|
150
|
+
function CycleTriggerView({ triggerName }) {
|
|
151
|
+
const issue = React.useContext(CycleTriggerIssueCtx)?.(triggerName) ?? null;
|
|
152
|
+
const chip = (_jsxs("span", { dir: "auto", className: "inline-flex items-center gap-1 rounded bg-selected/70 px-1.5 text-secondary-foreground", "data-cycle-trigger": true, "data-issue": issue ? '' : undefined, children: ["/", triggerName, issue ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("span", { className: "inline-flex text-destructive", "aria-label": issue }), children: _jsx(TriangleAlert, { className: "size-3.5", "aria-hidden": true }) }), _jsx(TooltipContent, { children: issue })] })) : null] }));
|
|
153
|
+
return issue ? _jsx(TooltipProvider, { children: chip }) : chip;
|
|
154
|
+
}
|
|
155
|
+
export const $createCycleTriggerNode = (triggerName) => new CycleTriggerNode({ triggerName });
|
|
156
|
+
export const $isCycleTriggerNode = (node) => node instanceof CycleTriggerNode;
|
|
157
|
+
/** Custom node that round-trips with contracts/chat/adapters/lexical.ts (`type: 'stream-chip'`). */
|
|
158
|
+
export class StreamChipNode extends DecoratorNode {
|
|
159
|
+
__streamId;
|
|
160
|
+
__title;
|
|
161
|
+
__visibility;
|
|
162
|
+
static getType() {
|
|
163
|
+
return 'stream-chip';
|
|
164
|
+
}
|
|
165
|
+
static clone(node) {
|
|
166
|
+
return new StreamChipNode({ streamId: node.__streamId, title: node.__title, visibility: node.__visibility }, node.__key);
|
|
167
|
+
}
|
|
168
|
+
constructor(payload, key) {
|
|
169
|
+
super(key);
|
|
170
|
+
this.__streamId = payload.streamId;
|
|
171
|
+
this.__title = payload.title;
|
|
172
|
+
this.__visibility = payload.visibility;
|
|
173
|
+
}
|
|
174
|
+
createDOM(_config) {
|
|
175
|
+
const span = document.createElement('span');
|
|
176
|
+
span.className = 'stream-chip';
|
|
177
|
+
span.dir = 'auto';
|
|
178
|
+
return span;
|
|
179
|
+
}
|
|
180
|
+
updateDOM() {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
184
|
+
static importJSON(serialized) {
|
|
185
|
+
return new StreamChipNode({
|
|
186
|
+
streamId: serialized.streamId,
|
|
187
|
+
title: serialized.title,
|
|
188
|
+
visibility: serialized.visibility,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
exportJSON() {
|
|
192
|
+
return {
|
|
193
|
+
type: 'stream-chip',
|
|
194
|
+
version: 1,
|
|
195
|
+
streamId: this.__streamId,
|
|
196
|
+
title: this.__title,
|
|
197
|
+
...(this.__visibility ? { visibility: this.__visibility } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
decorate() {
|
|
201
|
+
return _jsx(StreamChipView, { title: this.__title, visibility: this.__visibility });
|
|
202
|
+
}
|
|
203
|
+
isInline() {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export const $createStreamChipNode = (streamId, title, visibility) => new StreamChipNode({ streamId, title, visibility });
|
|
208
|
+
export const $isStreamChipNode = (node) => node instanceof StreamChipNode;
|
|
209
|
+
/** Labels for inserted-chip tooltips; RichTextEditorInner provides the resolved editor copy. */
|
|
210
|
+
const DEFAULT_STREAM_CHIP_LABELS = {
|
|
211
|
+
streamPrivate: 'Private',
|
|
212
|
+
streamPublic: 'Public',
|
|
213
|
+
streamFallback: 'stream',
|
|
214
|
+
};
|
|
215
|
+
const StreamChipLabelsCtx = React.createContext(DEFAULT_STREAM_CHIP_LABELS);
|
|
216
|
+
function StreamChipView({ title, visibility, }) {
|
|
217
|
+
const labels = React.useContext(StreamChipLabelsCtx);
|
|
218
|
+
const privateStream = visibility === 'private';
|
|
219
|
+
const chipClass = 'inline-flex items-center gap-0.5 rounded bg-primary/10 px-1 text-primary';
|
|
220
|
+
const label = (_jsxs(_Fragment, { children: [privateStream ? _jsx(Lock, { className: "size-3 shrink-0", "aria-hidden": true }) : null, "~", title || labels.streamFallback] }));
|
|
221
|
+
if (!visibility) {
|
|
222
|
+
return (_jsx("span", { dir: "auto", className: chipClass, "data-stream-chip": true, "data-visibility": "unset", children: label }));
|
|
223
|
+
}
|
|
224
|
+
const visibilityLabel = privateStream ? labels.streamPrivate : labels.streamPublic;
|
|
225
|
+
return (_jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("span", { dir: "auto", className: chipClass, "data-stream-chip": true, "data-visibility": visibility }), children: label }), _jsx(TooltipContent, { children: _jsxs("span", { className: "inline-flex items-center gap-1.5", children: [privateStream ? _jsx(Lock, { className: "size-3.5 shrink-0", "aria-hidden": true }) : null, visibilityLabel] }) })] }) }));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Account custom emoji in the composer — renders the image; serializes as `custom-emoji` and the
|
|
229
|
+
* lexical adapter stores `:name:` text in MessageContent.
|
|
230
|
+
*/
|
|
231
|
+
export class CustomEmojiNode extends DecoratorNode {
|
|
232
|
+
__name;
|
|
233
|
+
__imageUrl;
|
|
234
|
+
static getType() {
|
|
235
|
+
return 'custom-emoji';
|
|
236
|
+
}
|
|
237
|
+
static clone(node) {
|
|
238
|
+
return new CustomEmojiNode({ name: node.__name, imageUrl: node.__imageUrl }, node.__key);
|
|
239
|
+
}
|
|
240
|
+
constructor(payload, key) {
|
|
241
|
+
super(key);
|
|
242
|
+
this.__name = payload.name;
|
|
243
|
+
this.__imageUrl = payload.imageUrl;
|
|
244
|
+
}
|
|
245
|
+
createDOM(_config) {
|
|
246
|
+
const span = document.createElement('span');
|
|
247
|
+
span.className = 'custom-emoji';
|
|
248
|
+
span.dir = 'auto';
|
|
249
|
+
return span;
|
|
250
|
+
}
|
|
251
|
+
updateDOM() {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
255
|
+
static importJSON(serialized) {
|
|
256
|
+
return new CustomEmojiNode({ name: serialized.name, imageUrl: serialized.imageUrl });
|
|
257
|
+
}
|
|
258
|
+
exportJSON() {
|
|
259
|
+
return {
|
|
260
|
+
type: 'custom-emoji',
|
|
261
|
+
version: 1,
|
|
262
|
+
name: this.__name,
|
|
263
|
+
imageUrl: this.__imageUrl,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
decorate() {
|
|
267
|
+
return (_jsx("img", { src: this.__imageUrl, alt: `:${this.__name}:`, draggable: false, "data-custom-emoji": true, className: "mx-0.5 inline-block size-5 object-contain align-text-bottom" }));
|
|
268
|
+
}
|
|
269
|
+
isInline() {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
getTextContent() {
|
|
273
|
+
return `:${this.__name}:`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export const $createCustomEmojiNode = (name, imageUrl) => new CustomEmojiNode({ name, imageUrl });
|
|
277
|
+
export const $isCustomEmojiNode = (node) => node instanceof CustomEmojiNode;
|
|
278
|
+
/**
|
|
279
|
+
* Replaces the `/query` text at the start of the first paragraph with a `CycleTriggerNode` and a
|
|
280
|
+
* space. Refused (no-op) while the editor already holds a trigger: one `/trigger` per message.
|
|
281
|
+
*/
|
|
282
|
+
export const INSERT_CYCLE_TRIGGER_COMMAND = createCommand('INSERT_CYCLE_TRIGGER_COMMAND');
|
|
283
|
+
/** Inserts a `CustomEmojiNode` at the caret (picker / host-driven). */
|
|
284
|
+
export const INSERT_CUSTOM_EMOJI_COMMAND = createCommand('INSERT_CUSTOM_EMOJI_COMMAND');
|
|
285
|
+
// ── URL matcher for AutoLinkPlugin ───────────────────────────────────────────
|
|
286
|
+
/** Hostnames with a TLD, plus `localhost` (with optional port) for local / Docker paste. */
|
|
287
|
+
const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)|(https?:\/\/localhost(?::\d+)?(?:\/[-a-zA-Z0-9()@:%_+.~#?&//=]*)?)/i;
|
|
288
|
+
const MATCHERS = [
|
|
289
|
+
(text) => {
|
|
290
|
+
const match = URL_MATCHER.exec(text);
|
|
291
|
+
if (!match)
|
|
292
|
+
return null;
|
|
293
|
+
const fullMatch = match[0];
|
|
294
|
+
return {
|
|
295
|
+
index: match.index,
|
|
296
|
+
length: fullMatch.length,
|
|
297
|
+
text: fullMatch,
|
|
298
|
+
url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`,
|
|
299
|
+
attributes: { rel: 'noopener noreferrer', target: '_blank' },
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
/** Stable identity — AutoLinkPlugin re-registers when this array reference changes. */
|
|
304
|
+
const AUTO_LINK_EXCLUDE_PARENTS = [$isCodeNode];
|
|
305
|
+
// ── Code-block guards ────────────────────────────────────────────────────────
|
|
306
|
+
/**
|
|
307
|
+
* Fenced code is source text: skip prose transforms (typeahead, paste-as-link, …)
|
|
308
|
+
* when the selection intersects a CodeNode.
|
|
309
|
+
*/
|
|
310
|
+
function $selectionIntersectsCodeBlock() {
|
|
311
|
+
const selection = $getSelection();
|
|
312
|
+
if (!$isRangeSelection(selection))
|
|
313
|
+
return false;
|
|
314
|
+
for (const node of selection.getNodes()) {
|
|
315
|
+
if ($isCodeNode(node) || $findMatchingParent(node, $isCodeNode))
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
const anchor = selection.anchor.getNode();
|
|
319
|
+
return $isCodeNode(anchor) || Boolean($findMatchingParent(anchor, $isCodeNode));
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* True while the latest editor update only moved the caret. Typeahead menus open on edits, never
|
|
323
|
+
* on caret moves: an open menu takes ArrowUp/ArrowDown, so a menu that opens when the caret lands
|
|
324
|
+
* at the end of `#42` or `@bob` traps the caret on that line.
|
|
325
|
+
*/
|
|
326
|
+
const lastUpdateMovedCaretOnly = new WeakMap();
|
|
327
|
+
/** Must render before the typeahead plugins: Lexical runs update listeners in registration order. */
|
|
328
|
+
function TypeaheadEditGatePlugin() {
|
|
329
|
+
const [editor] = useLexicalComposerContext();
|
|
330
|
+
React.useEffect(() => editor.registerUpdateListener(({ dirtyLeaves, dirtyElements }) => {
|
|
331
|
+
const edited = dirtyLeaves.size > 0 || [...dirtyElements.values()].some(Boolean);
|
|
332
|
+
lastUpdateMovedCaretOnly.set(editor, !edited);
|
|
333
|
+
}), [editor]);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
/** Wrap a typeahead trigger so `@` / `:` / `#` never open inside a code block or on a caret move. */
|
|
337
|
+
function useTypeaheadOutsideCode(base) {
|
|
338
|
+
return React.useCallback((text, editor) => {
|
|
339
|
+
if (lastUpdateMovedCaretOnly.get(editor))
|
|
340
|
+
return null;
|
|
341
|
+
if ($selectionIntersectsCodeBlock())
|
|
342
|
+
return null;
|
|
343
|
+
return base(text, editor);
|
|
344
|
+
}, [base]);
|
|
345
|
+
}
|
|
346
|
+
// ── EnterSubmitPlugin ────────────────────────────────────────────────────────
|
|
347
|
+
const CODE_FENCE_LINE = /^[ \t]*`{3,}([\w-]+)?[ \t]*$/;
|
|
348
|
+
/**
|
|
349
|
+
* A paragraph that is only a ``` fence (optionally with a language) becomes an empty code
|
|
350
|
+
* block with the caret inside it. Lexical's markdown CODE transformer does the same on Enter;
|
|
351
|
+
* the composer moves that to Shift+Enter so Enter is always free to send.
|
|
352
|
+
*/
|
|
353
|
+
function $openCodeBlockFromFence() {
|
|
354
|
+
const selection = $getSelection();
|
|
355
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed())
|
|
356
|
+
return false;
|
|
357
|
+
const block = selection.anchor.getNode().getTopLevelElement();
|
|
358
|
+
if (!$isParagraphNode(block))
|
|
359
|
+
return false;
|
|
360
|
+
const match = CODE_FENCE_LINE.exec(block.getTextContent());
|
|
361
|
+
if (!match)
|
|
362
|
+
return false;
|
|
363
|
+
const code = $createCodeNode(match[1] || undefined);
|
|
364
|
+
block.replace(code);
|
|
365
|
+
code.select();
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
/** Backticks closing the paragraph, counted across the trailing text leaves (formatting splits them). */
|
|
369
|
+
function $trailingBacktickRun(block) {
|
|
370
|
+
let total = 0;
|
|
371
|
+
for (let node = block.getLastChild(); $isTextNode(node); node = node.getPreviousSibling()) {
|
|
372
|
+
const text = node.getTextContent();
|
|
373
|
+
const run = text.length - text.replace(/`+$/, '').length;
|
|
374
|
+
total += run;
|
|
375
|
+
if (run < text.length)
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
return total;
|
|
379
|
+
}
|
|
380
|
+
/** Removes the closing backticks and the space before them; the paragraph may end up empty. */
|
|
381
|
+
function $stripTrailingBackticks(block, count) {
|
|
382
|
+
let remaining = count;
|
|
383
|
+
for (let node = block.getLastChild(); remaining > 0 && $isTextNode(node);) {
|
|
384
|
+
const previous = node.getPreviousSibling();
|
|
385
|
+
const text = node.getTextContent();
|
|
386
|
+
const cut = Math.min(remaining, text.length - text.replace(/`+$/, '').length);
|
|
387
|
+
remaining -= cut;
|
|
388
|
+
if (cut === text.length)
|
|
389
|
+
node.remove();
|
|
390
|
+
else
|
|
391
|
+
node.setTextContent(text.slice(0, -cut));
|
|
392
|
+
node = previous;
|
|
393
|
+
}
|
|
394
|
+
const last = block.getLastChild();
|
|
395
|
+
if ($isTextNode(last)) {
|
|
396
|
+
const trimmed = last.getTextContent().trimEnd();
|
|
397
|
+
if (trimmed === '')
|
|
398
|
+
last.remove();
|
|
399
|
+
else
|
|
400
|
+
last.setTextContent(trimmed);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Typing the third backtick at the end of a top-level paragraph opens a code block right away, no
|
|
405
|
+
* Shift+Enter needed. On an empty line the paragraph becomes the block; after other text the text
|
|
406
|
+
* stays in its paragraph (minus the fence and the space before it) and the block opens on the line
|
|
407
|
+
* below. Only a one-character append that makes the paragraph end in exactly three backticks
|
|
408
|
+
* (counted across formatting boundaries) counts, so pasted or restored text, a fourth backtick, a
|
|
409
|
+
* fence with text after the caret, list items and undo/redo never convert. A language tag can no
|
|
410
|
+
* longer follow the fence by typing; Shift+Enter on a pasted or seeded ```ts line still opens a
|
|
411
|
+
* tagged block.
|
|
412
|
+
*/
|
|
413
|
+
function CodeFenceOnThirdBacktickPlugin() {
|
|
414
|
+
const [editor] = useLexicalComposerContext();
|
|
415
|
+
React.useEffect(() => editor.registerUpdateListener(({ editorState, prevEditorState, dirtyLeaves, tags }) => {
|
|
416
|
+
if (dirtyLeaves.size !== 1 || tags.has(HISTORIC_TAG) || tags.has(PASTE_TAG))
|
|
417
|
+
return;
|
|
418
|
+
const [key] = dirtyLeaves;
|
|
419
|
+
if (key === undefined)
|
|
420
|
+
return;
|
|
421
|
+
const typed = editorState.read(() => {
|
|
422
|
+
const selection = $getSelection();
|
|
423
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed())
|
|
424
|
+
return false;
|
|
425
|
+
const { anchor } = selection;
|
|
426
|
+
if (anchor.type !== 'text' || anchor.key !== key)
|
|
427
|
+
return false;
|
|
428
|
+
const node = $getNodeByKey(key);
|
|
429
|
+
if (!$isTextNode(node) || node.getNextSibling() !== null)
|
|
430
|
+
return false;
|
|
431
|
+
const text = node.getTextContent();
|
|
432
|
+
if (anchor.offset !== text.length || !text.endsWith('`'))
|
|
433
|
+
return false;
|
|
434
|
+
const block = node.getParent();
|
|
435
|
+
if (!$isParagraphNode(block) || !$isRootNode(block.getParent()))
|
|
436
|
+
return false;
|
|
437
|
+
if ($trailingBacktickRun(block) !== 3)
|
|
438
|
+
return false;
|
|
439
|
+
const before = prevEditorState.read(() => $getNodeByKey(key)?.getTextContent() ?? '');
|
|
440
|
+
return before === text.slice(0, -1);
|
|
441
|
+
});
|
|
442
|
+
if (!typed)
|
|
443
|
+
return;
|
|
444
|
+
editor.update(() => {
|
|
445
|
+
const node = $getNodeByKey(key);
|
|
446
|
+
const block = $isTextNode(node) ? node.getParent() : null;
|
|
447
|
+
if (!$isParagraphNode(block) || $trailingBacktickRun(block) !== 3)
|
|
448
|
+
return;
|
|
449
|
+
$stripTrailingBackticks(block, 3);
|
|
450
|
+
const code = $createCodeNode();
|
|
451
|
+
if (block.isEmpty())
|
|
452
|
+
block.replace(code);
|
|
453
|
+
else
|
|
454
|
+
block.insertAfter(code);
|
|
455
|
+
code.select();
|
|
456
|
+
});
|
|
457
|
+
}), [editor]);
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Shift+Enter on a trailing empty line inside a code block exits it (Lexical's stock exit needs
|
|
462
|
+
* two blank lines and runs at LOW — Enter is already taken for submit, so we own this here).
|
|
463
|
+
* Strips every trailing linebreak so the fence does not keep the blank line(s) used to leave.
|
|
464
|
+
*/
|
|
465
|
+
function $exitCodeBlockOnEmptyLine() {
|
|
466
|
+
const selection = $getSelection();
|
|
467
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed())
|
|
468
|
+
return false;
|
|
469
|
+
const { anchor } = selection;
|
|
470
|
+
if (anchor.type !== 'element')
|
|
471
|
+
return false;
|
|
472
|
+
const codeNode = anchor.getNode();
|
|
473
|
+
if (!$isCodeNode(codeNode))
|
|
474
|
+
return false;
|
|
475
|
+
const childrenSize = codeNode.getChildrenSize();
|
|
476
|
+
if (childrenSize < 1 || anchor.offset !== childrenSize)
|
|
477
|
+
return false;
|
|
478
|
+
if (!$isLineBreakNode(codeNode.getLastChild()))
|
|
479
|
+
return false;
|
|
480
|
+
while ($isLineBreakNode(codeNode.getLastChild())) {
|
|
481
|
+
codeNode.getLastChild().remove();
|
|
482
|
+
}
|
|
483
|
+
const paragraph = $createParagraphNode();
|
|
484
|
+
codeNode.insertAfter(paragraph);
|
|
485
|
+
paragraph.select();
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Enter (without Shift) submits — always, even inside a code block, a list item or after a
|
|
490
|
+
* ``` line — unless `sendOnEnter` is false (mobile: only the send button submits). Priority is
|
|
491
|
+
* NORMAL so:
|
|
492
|
+
* - markdown shortcuts (LOW) never see Enter, so ``` + Enter sends instead of opening a block
|
|
493
|
+
* - typeahead menus (HIGH) still win when open, so Enter picks a mention or emoji
|
|
494
|
+
* - RichTextPlugin's default Enter (EDITOR) does not steal submit
|
|
495
|
+
*
|
|
496
|
+
* Shift+Enter — and plain Enter when `sendOnEnter` is false — opens a code block from a ``` line,
|
|
497
|
+
* exits a code block from a trailing empty line (stripping those blank lines), otherwise stays a
|
|
498
|
+
* soft line break inside a code block, and outside code dispatches INSERT_PARAGRAPH
|
|
499
|
+
* (HAC `SendOnEnterPlugin` parity) so a new list item is created inside bullets/numbers.
|
|
500
|
+
*/
|
|
501
|
+
/** Deepest sub-list Tab can create; keeps nesting readable in a chat bubble. */
|
|
502
|
+
const MAX_LIST_INDENT = 4;
|
|
503
|
+
/**
|
|
504
|
+
* Tab / Shift+Tab indent / outdent a list item (a nested sub-list). Outside a list Tab is left to
|
|
505
|
+
* the browser so focus can leave the composer. EDITOR priority: an open typeahead claims Tab first.
|
|
506
|
+
*/
|
|
507
|
+
function ListTabIndentPlugin() {
|
|
508
|
+
const [editor] = useLexicalComposerContext();
|
|
509
|
+
React.useEffect(() => editor.registerCommand(KEY_TAB_COMMAND, (event) => {
|
|
510
|
+
const selection = $getSelection();
|
|
511
|
+
if (!$isRangeSelection(selection))
|
|
512
|
+
return false;
|
|
513
|
+
const items = new Set();
|
|
514
|
+
for (const node of [selection.anchor.getNode(), ...selection.getNodes()]) {
|
|
515
|
+
const item = $findMatchingParent(node, $isListItemNode);
|
|
516
|
+
if (item)
|
|
517
|
+
items.add(item);
|
|
518
|
+
}
|
|
519
|
+
if (items.size === 0)
|
|
520
|
+
return false;
|
|
521
|
+
event.preventDefault();
|
|
522
|
+
// setIndent nests / un-nests the item into a sub-list (@lexical/list $handleIndent).
|
|
523
|
+
for (const item of items) {
|
|
524
|
+
const indent = item.getIndent();
|
|
525
|
+
if (event.shiftKey) {
|
|
526
|
+
if (indent > 0)
|
|
527
|
+
item.setIndent(indent - 1);
|
|
528
|
+
}
|
|
529
|
+
else if (indent < MAX_LIST_INDENT) {
|
|
530
|
+
item.setIndent(indent + 1);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return true;
|
|
534
|
+
}, COMMAND_PRIORITY_EDITOR), [editor]);
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
function EnterSubmitPlugin({ onSubmit, sendOnEnter, }) {
|
|
538
|
+
const [editor] = useLexicalComposerContext();
|
|
539
|
+
React.useEffect(() => {
|
|
540
|
+
return editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
|
|
541
|
+
const newlineKey = event?.shiftKey || !sendOnEnter;
|
|
542
|
+
if (!newlineKey && onSubmit) {
|
|
543
|
+
event?.preventDefault();
|
|
544
|
+
onSubmit();
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
// Inside a code block: exit from a trailing empty line, else let Lexical insert a linebreak.
|
|
548
|
+
if ($selectionIntersectsCodeBlock()) {
|
|
549
|
+
if ($exitCodeBlockOnEmptyLine()) {
|
|
550
|
+
event?.preventDefault();
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
event?.preventDefault();
|
|
556
|
+
if ($openCodeBlockFromFence())
|
|
557
|
+
return true;
|
|
558
|
+
editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
|
|
559
|
+
return true;
|
|
560
|
+
}, COMMAND_PRIORITY_NORMAL);
|
|
561
|
+
}, [editor, onSubmit, sendOnEnter]);
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Inserts `request.text` at the current selection (or into a fresh paragraph)
|
|
566
|
+
* and focuses the editor so typeahead plugins see a real update — unlike
|
|
567
|
+
* remounting with a new `value`, which does not open `@` / `/` menus.
|
|
568
|
+
*
|
|
569
|
+
* Hosts must clear the request after `onInserted` (and before any remount that
|
|
570
|
+
* bumps the editor `key`) — otherwise a remount resets `handledNonce` and the
|
|
571
|
+
* same request re-inserts `@` / `/` and re-opens the typeahead.
|
|
572
|
+
*/
|
|
573
|
+
function InsertTextAtCaretPlugin({ request, onInserted, }) {
|
|
574
|
+
const [editor] = useLexicalComposerContext();
|
|
575
|
+
const handledNonce = React.useRef(null);
|
|
576
|
+
const stopRecheck = React.useRef(null);
|
|
577
|
+
React.useEffect(() => () => stopRecheck.current?.(), []);
|
|
578
|
+
React.useEffect(() => {
|
|
579
|
+
if (!request || handledNonce.current === request.nonce)
|
|
580
|
+
return;
|
|
581
|
+
handledNonce.current = request.nonce;
|
|
582
|
+
const { text } = request;
|
|
583
|
+
// A keyboard this focus raised can still move the viewport after the text lands; Lexical closes
|
|
584
|
+
// an open typeahead whose anchor a move leaves behind. Re-run its trigger check after each move.
|
|
585
|
+
stopRecheck.current?.();
|
|
586
|
+
stopRecheck.current = recheckTypeaheadOnViewportMoves(editor);
|
|
587
|
+
// Focus first so the DOM selection is inside the editor when `@` lands; the typeahead reads
|
|
588
|
+
// the DOM selection, so inserting before focus (iOS WebKit) leaves the menu closed.
|
|
589
|
+
editor.focus(() => editor.update(() => {
|
|
590
|
+
const selection = $getSelection();
|
|
591
|
+
if ($isRangeSelection(selection)) {
|
|
592
|
+
selection.insertText(text);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const root = $getRoot();
|
|
596
|
+
const last = root.getLastChild();
|
|
597
|
+
const paragraph = $isElementNode(last) ? last : $createParagraphNode();
|
|
598
|
+
if (paragraph.getParent() === null)
|
|
599
|
+
root.append(paragraph);
|
|
600
|
+
const node = $createTextNode(text);
|
|
601
|
+
paragraph.append(node);
|
|
602
|
+
node.selectEnd();
|
|
603
|
+
}), { defaultSelection: 'rootEnd' });
|
|
604
|
+
onInserted?.(request.nonce);
|
|
605
|
+
}, [editor, request, onInserted]);
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
const TYPEAHEAD_RECHECK_MS = 1500;
|
|
609
|
+
/** For a moment, re-runs the typeahead trigger check (an update listener) after viewport moves. */
|
|
610
|
+
const recheckTypeaheadOnViewportMoves = (editor) => {
|
|
611
|
+
const viewport = typeof window === 'undefined' ? null : window.visualViewport;
|
|
612
|
+
if (!viewport)
|
|
613
|
+
return () => undefined;
|
|
614
|
+
let frame = 0;
|
|
615
|
+
const recheck = () => {
|
|
616
|
+
cancelAnimationFrame(frame);
|
|
617
|
+
// After the frame in which Lexical repositions its anchor for this move.
|
|
618
|
+
frame = requestAnimationFrame(() => {
|
|
619
|
+
if (editor.getRootElement()?.contains(document.activeElement)) {
|
|
620
|
+
editor.update(() => $getRoot().markDirty());
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
};
|
|
624
|
+
const stop = () => {
|
|
625
|
+
clearTimeout(timer);
|
|
626
|
+
cancelAnimationFrame(frame);
|
|
627
|
+
viewport.removeEventListener('resize', recheck);
|
|
628
|
+
viewport.removeEventListener('scroll', recheck);
|
|
629
|
+
};
|
|
630
|
+
const timer = setTimeout(stop, TYPEAHEAD_RECHECK_MS);
|
|
631
|
+
viewport.addEventListener('resize', recheck);
|
|
632
|
+
viewport.addEventListener('scroll', recheck);
|
|
633
|
+
return stop;
|
|
634
|
+
};
|
|
635
|
+
// ── CycleTriggerPlugin ───────────────────────────────────────────────────────
|
|
636
|
+
function CycleTriggerPlugin({ request, onInserted, }) {
|
|
637
|
+
const [editor] = useLexicalComposerContext();
|
|
638
|
+
const handledNonce = React.useRef(null);
|
|
639
|
+
React.useEffect(() => {
|
|
640
|
+
return editor.registerCommand(INSERT_CYCLE_TRIGGER_COMMAND, ({ triggerName, replace }) => {
|
|
641
|
+
const existing = $nodesOfType(CycleTriggerNode);
|
|
642
|
+
const replacing = existing.length > 0;
|
|
643
|
+
if (replacing) {
|
|
644
|
+
if (!replace)
|
|
645
|
+
return true;
|
|
646
|
+
for (const node of existing) {
|
|
647
|
+
const after = node.getNextSibling();
|
|
648
|
+
// The old trigger's separating space goes with it, so the new one does not double it.
|
|
649
|
+
if ($isTextNode(after) && after.getTextContent().startsWith(' ')) {
|
|
650
|
+
const rest = after.getTextContent().slice(1);
|
|
651
|
+
if (rest === '')
|
|
652
|
+
after.remove();
|
|
653
|
+
else
|
|
654
|
+
after.setTextContent(rest);
|
|
655
|
+
}
|
|
656
|
+
node.remove();
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const root = $getRoot();
|
|
660
|
+
let paragraph = root.getFirstChild();
|
|
661
|
+
if (!paragraph) {
|
|
662
|
+
paragraph = $createParagraphNode();
|
|
663
|
+
root.append(paragraph);
|
|
664
|
+
}
|
|
665
|
+
if (!$isElementNode(paragraph))
|
|
666
|
+
return true;
|
|
667
|
+
const lead = paragraph.getFirstChild();
|
|
668
|
+
// Only a typed `/query` is swallowed. After swapping a chip, the text that follows is the
|
|
669
|
+
// prompt (it may start with `/api/users`), so it stays whole.
|
|
670
|
+
if (!replacing && $isTextNode(lead)) {
|
|
671
|
+
const rest = lead.getTextContent().replace(/^\/\S*\s?/, '');
|
|
672
|
+
if (rest === '')
|
|
673
|
+
lead.remove();
|
|
674
|
+
else
|
|
675
|
+
lead.setTextContent(rest);
|
|
676
|
+
}
|
|
677
|
+
const trigger = $createCycleTriggerNode(triggerName);
|
|
678
|
+
const space = $createTextNode(' ');
|
|
679
|
+
const anchor = paragraph.getFirstChild();
|
|
680
|
+
if (anchor)
|
|
681
|
+
anchor.insertBefore(trigger);
|
|
682
|
+
else
|
|
683
|
+
paragraph.append(trigger);
|
|
684
|
+
trigger.insertAfter(space);
|
|
685
|
+
space.select(1, 1);
|
|
686
|
+
return true;
|
|
687
|
+
}, COMMAND_PRIORITY_EDITOR);
|
|
688
|
+
}, [editor]);
|
|
689
|
+
React.useEffect(() => {
|
|
690
|
+
if (!request || handledNonce.current === request.nonce)
|
|
691
|
+
return;
|
|
692
|
+
handledNonce.current = request.nonce;
|
|
693
|
+
editor.dispatchCommand(INSERT_CYCLE_TRIGGER_COMMAND, {
|
|
694
|
+
triggerName: request.triggerName,
|
|
695
|
+
replace: request.replace,
|
|
696
|
+
});
|
|
697
|
+
editor.focus();
|
|
698
|
+
onInserted?.(request.nonce);
|
|
699
|
+
}, [editor, request, onInserted]);
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
// ── CustomEmojiPlugin ────────────────────────────────────────────────────────
|
|
703
|
+
function CustomEmojiPlugin({ request, onInserted, }) {
|
|
704
|
+
const [editor] = useLexicalComposerContext();
|
|
705
|
+
const handledNonce = React.useRef(null);
|
|
706
|
+
React.useEffect(() => {
|
|
707
|
+
return editor.registerCommand(INSERT_CUSTOM_EMOJI_COMMAND, ({ name, imageUrl }) => {
|
|
708
|
+
const emoji = $createCustomEmojiNode(name, imageUrl);
|
|
709
|
+
const selection = $getSelection();
|
|
710
|
+
if ($isRangeSelection(selection)) {
|
|
711
|
+
selection.insertNodes([emoji]);
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
const root = $getRoot();
|
|
715
|
+
const last = root.getLastChild();
|
|
716
|
+
const paragraph = $isElementNode(last) ? last : $createParagraphNode();
|
|
717
|
+
if (paragraph.getParent() === null)
|
|
718
|
+
root.append(paragraph);
|
|
719
|
+
paragraph.append(emoji);
|
|
720
|
+
emoji.selectEnd();
|
|
721
|
+
}
|
|
722
|
+
return true;
|
|
723
|
+
}, COMMAND_PRIORITY_EDITOR);
|
|
724
|
+
}, [editor]);
|
|
725
|
+
React.useEffect(() => {
|
|
726
|
+
if (!request || handledNonce.current === request.nonce)
|
|
727
|
+
return;
|
|
728
|
+
handledNonce.current = request.nonce;
|
|
729
|
+
editor.dispatchCommand(INSERT_CUSTOM_EMOJI_COMMAND, {
|
|
730
|
+
name: request.name,
|
|
731
|
+
imageUrl: request.imageUrl,
|
|
732
|
+
});
|
|
733
|
+
editor.focus();
|
|
734
|
+
onInserted?.(request.nonce);
|
|
735
|
+
}, [editor, request, onInserted]);
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
// ── Typeahead menus (mentions + slash) ───────────────────────────────────────
|
|
739
|
+
class MentionMenuOption extends MenuOption {
|
|
740
|
+
candidate;
|
|
741
|
+
constructor(candidate) {
|
|
742
|
+
super(isAllMentionCandidate(candidate) ? '__all__' : candidate.userId);
|
|
743
|
+
this.candidate = candidate;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
class SlashMenuOption extends MenuOption {
|
|
747
|
+
candidate;
|
|
748
|
+
constructor(candidate) {
|
|
749
|
+
super(candidate.key ?? candidate.name);
|
|
750
|
+
this.candidate = candidate;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
class EmojiMenuOption extends MenuOption {
|
|
754
|
+
candidate;
|
|
755
|
+
constructor(candidate) {
|
|
756
|
+
super(candidate.shortcode);
|
|
757
|
+
this.candidate = candidate;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
class StreamMenuOption extends MenuOption {
|
|
761
|
+
candidate;
|
|
762
|
+
constructor(candidate) {
|
|
763
|
+
super(candidate.streamId);
|
|
764
|
+
this.candidate = candidate;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const TYPEAHEAD_GAP_PX = 4;
|
|
768
|
+
/** Mobile: gap between the picker and the top of the composer footer (matches the run actions' 8px). */
|
|
769
|
+
const TYPEAHEAD_FOOTER_GAP_PX = 8;
|
|
770
|
+
/** A host marks the footer that holds the composer (and any actions above it) with this attribute. */
|
|
771
|
+
const COMPOSER_FOOTER_SELECTOR = '[data-composer-footer]';
|
|
772
|
+
/**
|
|
773
|
+
* Mobile / mweb: the picker sits above the whole composer footer, actions included, and spans the
|
|
774
|
+
* composer's width instead of tracking the caret. Null on desktop or when no host footer exists.
|
|
775
|
+
*/
|
|
776
|
+
function composerFooterBox(root) {
|
|
777
|
+
if (document.documentElement.dataset['platform'] !== 'mobile')
|
|
778
|
+
return null;
|
|
779
|
+
const footer = root?.closest(COMPOSER_FOOTER_SELECTOR);
|
|
780
|
+
if (!footer)
|
|
781
|
+
return null;
|
|
782
|
+
const rect = footer.getBoundingClientRect();
|
|
783
|
+
const style = window.getComputedStyle(footer);
|
|
784
|
+
const left = rect.left + (parseFloat(style.paddingLeft) || 0);
|
|
785
|
+
const right = rect.right - (parseFloat(style.paddingRight) || 0);
|
|
786
|
+
return {
|
|
787
|
+
left,
|
|
788
|
+
width: right - left,
|
|
789
|
+
bottom: window.innerHeight - rect.top + TYPEAHEAD_FOOTER_GAP_PX,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Mobile / mweb, editor outside any composer footer (the edit box of a message): the picker spans
|
|
794
|
+
* the editor's own width instead of starting at the caret, which would push it past the screen edge.
|
|
795
|
+
*/
|
|
796
|
+
function editorBox(root) {
|
|
797
|
+
if (document.documentElement.dataset['platform'] !== 'mobile' || !root)
|
|
798
|
+
return null;
|
|
799
|
+
const rect = root.getBoundingClientRect();
|
|
800
|
+
return { left: rect.left, width: rect.width };
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Overrides Lexical's inline `position: absolute` on its caret anchor. Each reposition clears the
|
|
804
|
+
* anchor's `top`, parking it below `<body>`'s end, then forces layout via `getRect()`. Absolute,
|
|
805
|
+
* that overflows the page; with classic (non-overlay) scrollbars a scrollbar flashes in and the
|
|
806
|
+
* centered composer column shifts ~7px, so Lexical records a wrong `left`. Fixed boxes never
|
|
807
|
+
* create page overflow. The document never scrolls, so viewport and page coordinates match.
|
|
808
|
+
*/
|
|
809
|
+
const TYPEAHEAD_ANCHOR_CLASS = 'fixed!';
|
|
810
|
+
/** Lexical's `positionMenu` places its anchor this far below the caret line's bottom edge. */
|
|
811
|
+
const LEXICAL_ANCHOR_OFFSET_PX = 3;
|
|
812
|
+
/** Ignore sub-pixel caret jitter so reposition listeners do not thrash React state. */
|
|
813
|
+
const TYPEAHEAD_COORDS_EPSILON_PX = 0.5;
|
|
814
|
+
/**
|
|
815
|
+
* Viewport Y of the top of the line holding the caret. Lexical's typeahead anchor sits *below*
|
|
816
|
+
* the caret (`caret.top + caret.height + 3`, with the caret's height), so pinning to the anchor's
|
|
817
|
+
* top would put the menu over the typed text. Prefer the live selection rect; keep the last good
|
|
818
|
+
* line while Lexical briefly reports a zero-height range mid-keystroke (that flicker previously
|
|
819
|
+
* bounced `bottom` every character); only then undo Lexical's anchor offset.
|
|
820
|
+
*/
|
|
821
|
+
function caretLineTop(root, anchor, lastGood) {
|
|
822
|
+
const selection = window.getSelection();
|
|
823
|
+
if (root && selection && selection.rangeCount > 0) {
|
|
824
|
+
const range = selection.getRangeAt(0);
|
|
825
|
+
if (root.contains(range.startContainer) && typeof range.getBoundingClientRect === 'function') {
|
|
826
|
+
const rect = range.getBoundingClientRect();
|
|
827
|
+
if (rect.height > 0) {
|
|
828
|
+
lastGood.current = rect.top;
|
|
829
|
+
return rect.top;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (lastGood.current != null)
|
|
834
|
+
return lastGood.current;
|
|
835
|
+
const rect = anchor.getBoundingClientRect();
|
|
836
|
+
return rect.top - rect.height - LEXICAL_ANCHOR_OFFSET_PX;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Keep typeahead lists pinned above the caret line (composer sits at the pane bottom).
|
|
840
|
+
* Mentions, slash and emoji share this shell; each sets its own `data-*` marker for tests.
|
|
841
|
+
*
|
|
842
|
+
* Portaled to `document.body` (not Lexical's caret anchor): Lexical's `useMenuAnchorRef`
|
|
843
|
+
* recreates `positionMenu` whenever `resolution` changes and the effect cleanup calls
|
|
844
|
+
* `containerDiv.remove()` — that runs on every keystroke inside an open `/` or `@` query.
|
|
845
|
+
* A portal parented to that node is torn out of the DOM and remounted each character, which
|
|
846
|
+
* reads as the dialogue shaking. Body keeps the React tree mounted; we only track the anchor
|
|
847
|
+
* for caret geometry.
|
|
848
|
+
*
|
|
849
|
+
* Coordinates are written straight to the menu node (no React state) so
|
|
850
|
+
* `selectionchange` / MutationObserver churn cannot re-render the option list every frame.
|
|
851
|
+
*
|
|
852
|
+
* Lexical's `scrollIntoViewIfNeeded` uses `target.closest('#typeahead-menu')` to find the
|
|
853
|
+
* container. With the menu on `document.body` carrying `id="typeahead-menu"`, it finds our
|
|
854
|
+
* on-screen menu. The menu is always in-viewport (position:fixed), so the container-scroll
|
|
855
|
+
* branch never fires — no page scroll, no shake.
|
|
856
|
+
*/
|
|
857
|
+
/** Fire when the scrollable menu is within this many px of its end (load next page). */
|
|
858
|
+
const TYPEAHEAD_NEAR_END_PX = 32;
|
|
859
|
+
/** Same as the `max-h-60` class: the tallest a typeahead list gets when there is room. */
|
|
860
|
+
const TYPEAHEAD_MAX_HEIGHT_PX = 240;
|
|
861
|
+
/** About two rows: what the list gets in the last-resort spot when nothing fits above the composer. */
|
|
862
|
+
const TYPEAHEAD_MIN_HEIGHT_PX = 88;
|
|
863
|
+
/** Below this much room above the composer not even one row shows, so the list moves to the last-resort spot. */
|
|
864
|
+
const TYPEAHEAD_ONE_ROW_PX = 48;
|
|
865
|
+
/** Gap kept between the list and the top of the visible area. */
|
|
866
|
+
const TYPEAHEAD_TOP_MARGIN_PX = 8;
|
|
867
|
+
/** Height of the native iOS bar under the status bar; its controls take the touches. */
|
|
868
|
+
const NATIVE_BAR_PX = 44;
|
|
869
|
+
/** `env(safe-area-inset-top)` in px, read live; 0 when the page has no safe area. */
|
|
870
|
+
function measureSafeAreaTopPx() {
|
|
871
|
+
const probe = document.createElement('div');
|
|
872
|
+
probe.style.cssText =
|
|
873
|
+
'position:fixed;top:0;left:0;height:env(safe-area-inset-top,0px);visibility:hidden;pointer-events:none;';
|
|
874
|
+
document.documentElement.appendChild(probe);
|
|
875
|
+
const height = probe.getBoundingClientRect().height;
|
|
876
|
+
probe.remove();
|
|
877
|
+
return height;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* What covers the top of the page: the safe-area inset (0 on desktop, Android and plain mobile
|
|
881
|
+
* Safari tabs) plus the 44px bar only the iOS host draws; `env()` cannot report that bar.
|
|
882
|
+
*/
|
|
883
|
+
function topChromePx() {
|
|
884
|
+
const inNativeHost = window.__STREAMS_NATIVE__ !== undefined;
|
|
885
|
+
return measureSafeAreaTopPx() + (inNativeHost ? NATIVE_BAR_PX : 0);
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* The part of the layout viewport the user sees, in layout-viewport Y (the menu is `position:
|
|
889
|
+
* fixed`, so everything here shares the coordinates of `getBoundingClientRect`). The visual
|
|
890
|
+
* viewport is a window into the layout viewport, so it can neither start below nor end past it.
|
|
891
|
+
* Inside the iOS app `offsetTop` has been seen to stay at the keyboard pan after the page was
|
|
892
|
+
* resized to sit above the keyboard: taken as is, it moved the top limit below the composer and
|
|
893
|
+
* left the list two rows tall. The clamp makes that report harmless.
|
|
894
|
+
*/
|
|
895
|
+
export function visibleArea({ layoutHeight, offsetTop, height, }) {
|
|
896
|
+
const visibleHeight = Math.min(height ?? layoutHeight, layoutHeight);
|
|
897
|
+
const topY = Math.min(Math.max(offsetTop ?? 0, 0), layoutHeight - visibleHeight);
|
|
898
|
+
return { topY, bottomY: topY + visibleHeight };
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Where the list's bottom edge goes and how tall it may be, in layout-viewport Y. The list sits
|
|
902
|
+
* above `preferredBottomY` (the composer footer top, or the caret line without a footer) and below
|
|
903
|
+
* the chrome, and never leaves `[visibleTopY, visibleBottomY]`. Its height is the room between
|
|
904
|
+
* them, up to 240px; a cramped screen shows fewer rows rather than rows under the native bar.
|
|
905
|
+
* Only a mobile composer footer (`coverDraftWhenCramped`) has a last resort: when under one row
|
|
906
|
+
* fits above the composer (landscape with the keyboard up), the list takes the top of the safe
|
|
907
|
+
* area and covers the draft, because a list nobody can reach is worse. Without a footer (desktop,
|
|
908
|
+
* inline edit) the bottom edge stays on the caret line so the typed text is never covered.
|
|
909
|
+
*/
|
|
910
|
+
export function placeTypeahead({ preferredBottomY, visibleTopY, visibleBottomY, topChromePx: chromePx, coverDraftWhenCramped = false, }) {
|
|
911
|
+
const topY = visibleTopY + chromePx + TYPEAHEAD_TOP_MARGIN_PX;
|
|
912
|
+
let bottomY = Math.min(preferredBottomY, visibleBottomY);
|
|
913
|
+
if (coverDraftWhenCramped && bottomY - topY < TYPEAHEAD_ONE_ROW_PX) {
|
|
914
|
+
bottomY = Math.min(visibleBottomY, topY + TYPEAHEAD_MIN_HEIGHT_PX);
|
|
915
|
+
}
|
|
916
|
+
return {
|
|
917
|
+
bottomY,
|
|
918
|
+
maxHeight: Math.round(Math.max(0, Math.min(TYPEAHEAD_MAX_HEIGHT_PX, bottomY - topY))),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Resolves the menu's CSS box from live geometry (nothing cached, so insets, keyboard and
|
|
923
|
+
* rotation are always current). Mobile with a composer footer: above the footer. Otherwise
|
|
924
|
+
* above the caret line; on mobile the editor's own width is used (see `editorBox`).
|
|
925
|
+
*/
|
|
926
|
+
function placementFor(root, anchor, line) {
|
|
927
|
+
const footerBox = composerFooterBox(root);
|
|
928
|
+
const caretBottom = window.innerHeight - line + TYPEAHEAD_GAP_PX;
|
|
929
|
+
const visual = window.visualViewport;
|
|
930
|
+
const visible = visibleArea({
|
|
931
|
+
layoutHeight: window.innerHeight,
|
|
932
|
+
offsetTop: visual?.offsetTop,
|
|
933
|
+
height: visual?.height,
|
|
934
|
+
});
|
|
935
|
+
const placed = placeTypeahead({
|
|
936
|
+
preferredBottomY: window.innerHeight - (footerBox ? footerBox.bottom : caretBottom),
|
|
937
|
+
visibleTopY: visible.topY,
|
|
938
|
+
visibleBottomY: visible.bottomY,
|
|
939
|
+
topChromePx: topChromePx(),
|
|
940
|
+
coverDraftWhenCramped: footerBox !== null,
|
|
941
|
+
});
|
|
942
|
+
const box = footerBox ?? editorBox(root);
|
|
943
|
+
return {
|
|
944
|
+
left: box ? box.left : anchor.getBoundingClientRect().left,
|
|
945
|
+
width: box?.width,
|
|
946
|
+
bottom: window.innerHeight - placed.bottomY,
|
|
947
|
+
maxHeight: placed.maxHeight,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
function TypeaheadMenuAboveCaret({ anchor, dataAttr, className, children, optionCount, selectedIndex, setHighlightedIndex, onNearEnd, }) {
|
|
951
|
+
const [editor] = useLexicalComposerContext();
|
|
952
|
+
const menuRef = React.useRef(null);
|
|
953
|
+
const lastLineTopRef = React.useRef(null);
|
|
954
|
+
const placementRef = React.useRef(null);
|
|
955
|
+
const rafRef = React.useRef(null);
|
|
956
|
+
// First paint only; live updates write the menu node directly (no re-render). Reads the anchor
|
|
957
|
+
// instead of the selection so no ref is touched during render; the layout effect corrects it.
|
|
958
|
+
const [initialPlacement] = React.useState(() => {
|
|
959
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
960
|
+
const line = anchorRect.top - anchorRect.height - LEXICAL_ANCHOR_OFFSET_PX;
|
|
961
|
+
return placementFor(editor.getRootElement(), anchor, line);
|
|
962
|
+
});
|
|
963
|
+
React.useLayoutEffect(() => {
|
|
964
|
+
const apply = () => {
|
|
965
|
+
rafRef.current = null;
|
|
966
|
+
const menu = menuRef.current;
|
|
967
|
+
if (!menu)
|
|
968
|
+
return;
|
|
969
|
+
const line = caretLineTop(editor.getRootElement(), anchor, lastLineTopRef);
|
|
970
|
+
const next = placementFor(editor.getRootElement(), anchor, line);
|
|
971
|
+
const prev = placementRef.current;
|
|
972
|
+
if (prev &&
|
|
973
|
+
Math.abs(prev.left - next.left) < TYPEAHEAD_COORDS_EPSILON_PX &&
|
|
974
|
+
Math.abs(prev.bottom - next.bottom) < TYPEAHEAD_COORDS_EPSILON_PX &&
|
|
975
|
+
prev.width === next.width &&
|
|
976
|
+
prev.maxHeight === next.maxHeight) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
placementRef.current = next;
|
|
980
|
+
menu.style.maxHeight = `${next.maxHeight}px`;
|
|
981
|
+
menu.style.left = `${next.left}px`;
|
|
982
|
+
menu.style.bottom = `${next.bottom}px`;
|
|
983
|
+
menu.style.width = next.width === undefined ? '' : `${next.width}px`;
|
|
984
|
+
};
|
|
985
|
+
const update = () => {
|
|
986
|
+
if (rafRef.current != null)
|
|
987
|
+
return;
|
|
988
|
+
rafRef.current = window.requestAnimationFrame(apply);
|
|
989
|
+
};
|
|
990
|
+
update();
|
|
991
|
+
window.addEventListener('resize', update);
|
|
992
|
+
// Capture scrolls from any scroll parent (chat timeline, composer).
|
|
993
|
+
window.addEventListener('scroll', update, true);
|
|
994
|
+
document.addEventListener('selectionchange', update);
|
|
995
|
+
// The iOS keyboard pans or resizes the visual viewport without a window `resize`.
|
|
996
|
+
const visual = window.visualViewport;
|
|
997
|
+
visual?.addEventListener('resize', update);
|
|
998
|
+
visual?.addEventListener('scroll', update);
|
|
999
|
+
// The footer grows or shrinks without any viewport event: attachments, the formatting row, run
|
|
1000
|
+
// controls, a draft that wraps. Its top edge is the list's bottom edge.
|
|
1001
|
+
const footerObserver = new ResizeObserver(update);
|
|
1002
|
+
const footer = editor.getRootElement()?.closest(COMPOSER_FOOTER_SELECTOR);
|
|
1003
|
+
if (footer)
|
|
1004
|
+
footerObserver.observe(footer);
|
|
1005
|
+
// Lexical writes top/left on the absolute anchor when the caret moves.
|
|
1006
|
+
const observer = new MutationObserver(update);
|
|
1007
|
+
observer.observe(anchor, { attributes: true, attributeFilter: ['style'] });
|
|
1008
|
+
return () => {
|
|
1009
|
+
if (rafRef.current != null)
|
|
1010
|
+
window.cancelAnimationFrame(rafRef.current);
|
|
1011
|
+
rafRef.current = null;
|
|
1012
|
+
window.removeEventListener('resize', update);
|
|
1013
|
+
window.removeEventListener('scroll', update, true);
|
|
1014
|
+
document.removeEventListener('selectionchange', update);
|
|
1015
|
+
visual?.removeEventListener('resize', update);
|
|
1016
|
+
visual?.removeEventListener('scroll', update);
|
|
1017
|
+
footerObserver.disconnect();
|
|
1018
|
+
observer.disconnect();
|
|
1019
|
+
};
|
|
1020
|
+
}, [anchor, editor]);
|
|
1021
|
+
// Strip the id Lexical stamps on the anchor so `aria-controls="typeahead-menu"` on the
|
|
1022
|
+
// editor root resolves to our visible menu (which declares `id` in JSX), not to the
|
|
1023
|
+
// caret-sized anchor parked off-screen.
|
|
1024
|
+
React.useLayoutEffect(() => {
|
|
1025
|
+
const stripId = () => {
|
|
1026
|
+
if (anchor.getAttribute('id') === 'typeahead-menu')
|
|
1027
|
+
anchor.removeAttribute('id');
|
|
1028
|
+
};
|
|
1029
|
+
stripId();
|
|
1030
|
+
const obs = new MutationObserver(stripId);
|
|
1031
|
+
obs.observe(anchor, { attributes: true, attributeFilter: ['id'] });
|
|
1032
|
+
return () => obs.disconnect();
|
|
1033
|
+
}, [anchor]);
|
|
1034
|
+
// Lexical's typeahead sets selectedIndex to -1 when ArrowDown/Up fire while `options` is still
|
|
1035
|
+
// empty (async search). That value is not `null`, so the built-in preselect never recovers —
|
|
1036
|
+
// nothing highlights and Enter falls through to submit. Re-clamp once candidates arrive.
|
|
1037
|
+
React.useLayoutEffect(() => {
|
|
1038
|
+
if (optionCount === undefined || setHighlightedIndex === undefined)
|
|
1039
|
+
return;
|
|
1040
|
+
if (optionCount === 0)
|
|
1041
|
+
return;
|
|
1042
|
+
if (selectedIndex === null ||
|
|
1043
|
+
selectedIndex === undefined ||
|
|
1044
|
+
selectedIndex < 0 ||
|
|
1045
|
+
selectedIndex >= optionCount) {
|
|
1046
|
+
setHighlightedIndex(0);
|
|
1047
|
+
}
|
|
1048
|
+
}, [optionCount, selectedIndex, setHighlightedIndex]);
|
|
1049
|
+
// When the list grows but still fits without a scrollbar, keep fetching until it scrolls or
|
|
1050
|
+
// the host reports no more pages (otherwise page 2+ would never load).
|
|
1051
|
+
React.useLayoutEffect(() => {
|
|
1052
|
+
if (!onNearEnd)
|
|
1053
|
+
return;
|
|
1054
|
+
const el = menuRef.current;
|
|
1055
|
+
if (!el)
|
|
1056
|
+
return;
|
|
1057
|
+
if (el.scrollHeight <= el.clientHeight + TYPEAHEAD_NEAR_END_PX) {
|
|
1058
|
+
onNearEnd();
|
|
1059
|
+
}
|
|
1060
|
+
}, [onNearEnd, optionCount]);
|
|
1061
|
+
return (_jsx("div", { ref: menuRef, id: "typeahead-menu", [dataAttr]: '', className: cn('z-(--z-dropdown) max-h-60 min-w-[8rem] overflow-y-auto rounded-md border bg-popover p-1 shadow-md', className),
|
|
1062
|
+
// `top: auto` clears a unitless `style.top` if Lexical ever targets this node.
|
|
1063
|
+
style: {
|
|
1064
|
+
position: 'fixed',
|
|
1065
|
+
top: 'auto',
|
|
1066
|
+
left: initialPlacement.left,
|
|
1067
|
+
bottom: initialPlacement.bottom,
|
|
1068
|
+
width: initialPlacement.width,
|
|
1069
|
+
// Beats `max-h-60` when the room above the menu is smaller than 240px.
|
|
1070
|
+
maxHeight: initialPlacement.maxHeight,
|
|
1071
|
+
}, onScroll: onNearEnd
|
|
1072
|
+
? (event) => {
|
|
1073
|
+
const el = event.currentTarget;
|
|
1074
|
+
if (el.scrollTop + el.clientHeight >= el.scrollHeight - TYPEAHEAD_NEAR_END_PX) {
|
|
1075
|
+
onNearEnd();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
: undefined, children: children }));
|
|
1079
|
+
}
|
|
1080
|
+
function MentionsPluginWithPortal({ searchMentions, onMentionSelect, }) {
|
|
1081
|
+
const [editor] = useLexicalComposerContext();
|
|
1082
|
+
const [options, setOptions] = React.useState([]);
|
|
1083
|
+
const [status, setStatus] = React.useState('ready');
|
|
1084
|
+
const [hasMore, setHasMore] = React.useState(false);
|
|
1085
|
+
const [loadingMore, setLoadingMore] = React.useState(false);
|
|
1086
|
+
const requestIdRef = React.useRef(0);
|
|
1087
|
+
// Remember the open `@` query so we can remint options when `searchMentions` itself changes
|
|
1088
|
+
// (directory late-load, agent avatar blob URLs settling) without another keystroke.
|
|
1089
|
+
const activeQueryRef = React.useRef(null);
|
|
1090
|
+
const optionsRef = React.useRef(options);
|
|
1091
|
+
const hasMoreRef = React.useRef(hasMore);
|
|
1092
|
+
const loadingMoreRef = React.useRef(false);
|
|
1093
|
+
// Lexical keeps the highlight as an index. A refresh that puts rows ahead of it would move it to
|
|
1094
|
+
// another person, so the refresh re-points it at the same option.
|
|
1095
|
+
const highlightRef = React.useRef({ index: null, set: null });
|
|
1096
|
+
React.useEffect(() => {
|
|
1097
|
+
optionsRef.current = options;
|
|
1098
|
+
}, [options]);
|
|
1099
|
+
React.useEffect(() => {
|
|
1100
|
+
hasMoreRef.current = hasMore;
|
|
1101
|
+
}, [hasMore]);
|
|
1102
|
+
const mentionTriggerMatch = useBasicTypeaheadTriggerMatch('@', { minLength: 0 });
|
|
1103
|
+
const checkForTriggerMatch = useTypeaheadOutsideCode(mentionTriggerMatch);
|
|
1104
|
+
const onQueryChange = React.useCallback(async (query) => {
|
|
1105
|
+
activeQueryRef.current = query;
|
|
1106
|
+
loadingMoreRef.current = false;
|
|
1107
|
+
setLoadingMore(false);
|
|
1108
|
+
if (query === null || !searchMentions) {
|
|
1109
|
+
requestIdRef.current += 1;
|
|
1110
|
+
setOptions([]);
|
|
1111
|
+
setHasMore(false);
|
|
1112
|
+
setStatus('ready');
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
const requestId = ++requestIdRef.current;
|
|
1116
|
+
setStatus('loading');
|
|
1117
|
+
// Keep the previous list visible while the next query resolves so ArrowUp/Down still have
|
|
1118
|
+
// rows to move through (Lexical swallows those keys whenever a custom menuRenderFn is set).
|
|
1119
|
+
try {
|
|
1120
|
+
const page = await searchMentions(query, { offset: 0, limit: MENTION_PAGE_SIZE });
|
|
1121
|
+
if (requestId !== requestIdRef.current)
|
|
1122
|
+
return;
|
|
1123
|
+
const first = page.items.map((c) => new MentionMenuOption(c));
|
|
1124
|
+
setOptions(first);
|
|
1125
|
+
setHasMore(page.refresh ? false : page.hasMore);
|
|
1126
|
+
// With a refresh on its way, an empty local page keeps "Searching…" instead of "No matches".
|
|
1127
|
+
setStatus(first.length === 0 ? (page.refresh ? 'loading' : 'empty') : 'ready');
|
|
1128
|
+
if (!page.refresh)
|
|
1129
|
+
return;
|
|
1130
|
+
const fresh = await page.refresh.catch(() => null);
|
|
1131
|
+
if (requestId !== requestIdRef.current)
|
|
1132
|
+
return;
|
|
1133
|
+
const next = fresh ? fresh.items.map((c) => new MentionMenuOption(c)) : first;
|
|
1134
|
+
const { index, set } = highlightRef.current;
|
|
1135
|
+
const highlightedKey = index === null ? undefined : optionsRef.current[index]?.key;
|
|
1136
|
+
setOptions(next);
|
|
1137
|
+
if (fresh && set && highlightedKey !== undefined) {
|
|
1138
|
+
const moved = next.findIndex((option) => option.key === highlightedKey);
|
|
1139
|
+
// A highlighted row the server no longer lists falls back to the first row.
|
|
1140
|
+
set(Math.max(moved, 0));
|
|
1141
|
+
}
|
|
1142
|
+
setHasMore(fresh?.hasMore ?? false);
|
|
1143
|
+
setStatus(next.length === 0 ? 'empty' : 'ready');
|
|
1144
|
+
}
|
|
1145
|
+
catch {
|
|
1146
|
+
if (requestId !== requestIdRef.current)
|
|
1147
|
+
return;
|
|
1148
|
+
setOptions([]);
|
|
1149
|
+
setHasMore(false);
|
|
1150
|
+
setStatus('empty');
|
|
1151
|
+
}
|
|
1152
|
+
}, [searchMentions]);
|
|
1153
|
+
React.useEffect(() => {
|
|
1154
|
+
const query = activeQueryRef.current;
|
|
1155
|
+
if (query === null || !searchMentions)
|
|
1156
|
+
return;
|
|
1157
|
+
void onQueryChange(query);
|
|
1158
|
+
}, [onQueryChange, searchMentions]);
|
|
1159
|
+
const loadMore = React.useCallback(() => {
|
|
1160
|
+
if (!searchMentions)
|
|
1161
|
+
return;
|
|
1162
|
+
const query = activeQueryRef.current;
|
|
1163
|
+
if (query === null)
|
|
1164
|
+
return;
|
|
1165
|
+
if (!hasMoreRef.current || loadingMoreRef.current)
|
|
1166
|
+
return;
|
|
1167
|
+
const requestId = requestIdRef.current;
|
|
1168
|
+
const offset = optionsRef.current.filter((option) => !isAllMentionCandidate(option.candidate)).length;
|
|
1169
|
+
loadingMoreRef.current = true;
|
|
1170
|
+
setLoadingMore(true);
|
|
1171
|
+
void (async () => {
|
|
1172
|
+
try {
|
|
1173
|
+
const page = await searchMentions(query, { offset, limit: MENTION_PAGE_SIZE });
|
|
1174
|
+
if (requestId !== requestIdRef.current)
|
|
1175
|
+
return;
|
|
1176
|
+
const seen = new Set(optionsRef.current.map((option) => option.key));
|
|
1177
|
+
const appended = page.items
|
|
1178
|
+
.map((candidate) => new MentionMenuOption(candidate))
|
|
1179
|
+
.filter((option) => !seen.has(option.key));
|
|
1180
|
+
// Empty/duplicate pages must clear hasMore or the near-end effect never stops.
|
|
1181
|
+
if (appended.length === 0) {
|
|
1182
|
+
setHasMore(false);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
setOptions((prev) => [...prev, ...appended]);
|
|
1186
|
+
setHasMore(page.hasMore);
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
if (requestId !== requestIdRef.current)
|
|
1190
|
+
return;
|
|
1191
|
+
setHasMore(false);
|
|
1192
|
+
}
|
|
1193
|
+
finally {
|
|
1194
|
+
if (requestId === requestIdRef.current) {
|
|
1195
|
+
loadingMoreRef.current = false;
|
|
1196
|
+
setLoadingMore(false);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
})();
|
|
1200
|
+
}, [searchMentions]);
|
|
1201
|
+
const onSelectOption = React.useCallback((selectedOption, nodeToReplace, closeMenu) => {
|
|
1202
|
+
const candidate = selectedOption.candidate;
|
|
1203
|
+
// Disabled agents stay in the list for discoverability but Enter/Tab/click must not insert.
|
|
1204
|
+
if (!isAllMentionCandidate(candidate) && candidate.disabled)
|
|
1205
|
+
return;
|
|
1206
|
+
editor.update(() => {
|
|
1207
|
+
const node = isAllMentionCandidate(candidate)
|
|
1208
|
+
? new MentionNode({ all: true })
|
|
1209
|
+
: new MentionNode({
|
|
1210
|
+
userId: candidate.userId,
|
|
1211
|
+
displayName: candidate.displayName,
|
|
1212
|
+
});
|
|
1213
|
+
if (nodeToReplace) {
|
|
1214
|
+
nodeToReplace.replace(node);
|
|
1215
|
+
}
|
|
1216
|
+
// Trailing space so the next word can be typed immediately (same as /trigger).
|
|
1217
|
+
const space = $createTextNode(' ');
|
|
1218
|
+
node.insertAfter(space);
|
|
1219
|
+
space.select(1, 1);
|
|
1220
|
+
closeMenu();
|
|
1221
|
+
});
|
|
1222
|
+
onMentionSelect?.(candidate);
|
|
1223
|
+
}, [editor, onMentionSelect]);
|
|
1224
|
+
if (!searchMentions)
|
|
1225
|
+
return null;
|
|
1226
|
+
return (_jsx(LexicalTypeaheadMenuPlugin, { onQueryChange: onQueryChange, onSelectOption: onSelectOption, triggerFn: checkForTriggerMatch, options: options,
|
|
1227
|
+
// Above EnterSubmitPlugin (NORMAL) so Enter picks a mention instead of sending.
|
|
1228
|
+
commandPriority: COMMAND_PRIORITY_HIGH, anchorClassName: TYPEAHEAD_ANCHOR_CLASS, menuRenderFn: (anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
|
1229
|
+
if (!anchorElementRef.current)
|
|
1230
|
+
return null;
|
|
1231
|
+
highlightRef.current = { index: selectedIndex, set: setHighlightedIndex };
|
|
1232
|
+
// Portal to body — not Lexical's caret anchor (that node is removed/re-appended on every
|
|
1233
|
+
// keystroke; see TypeaheadMenuAboveCaret). Fixed + bottom pins the list above the caret.
|
|
1234
|
+
return ReactDOM.createPortal(_jsx(TypeaheadMenuAboveCaret, { anchor: anchorElementRef.current, dataAttr: "data-mention-menu", className: "w-64", optionCount: options.length, selectedIndex: selectedIndex, setHighlightedIndex: setHighlightedIndex, onNearEnd: hasMore ? loadMore : undefined, children: options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", role: "status", children: status === 'loading' ? 'Searching…' : 'No matches' })) : (_jsxs(_Fragment, { children: [options.map((option, index) => {
|
|
1235
|
+
const selected = selectedIndex === index;
|
|
1236
|
+
const candidate = option.candidate;
|
|
1237
|
+
const all = isAllMentionCandidate(candidate);
|
|
1238
|
+
const agent = !all && candidate.kind === 'agent';
|
|
1239
|
+
const disabled = !all && Boolean(candidate.disabled);
|
|
1240
|
+
const avatarUrl = isAllMentionCandidate(candidate) ? null : candidate.avatarUrl;
|
|
1241
|
+
const agentPicture = agent
|
|
1242
|
+
? (candidate.agentPicture ??
|
|
1243
|
+
(avatarUrl ? { kind: 'image', src: avatarUrl } : null))
|
|
1244
|
+
: null;
|
|
1245
|
+
const providerBadge = agent ? candidate.providerBadge : null;
|
|
1246
|
+
const subtitle = isAllMentionCandidate(candidate)
|
|
1247
|
+
? candidate.subtitle
|
|
1248
|
+
: disabled
|
|
1249
|
+
? candidate.disabledReason
|
|
1250
|
+
: candidate.subtitle;
|
|
1251
|
+
return (_jsxs("div", { id: `typeahead-item-${index}`, role: "option", tabIndex: -1, "aria-selected": selected, "aria-disabled": disabled || undefined, ref: option.setRefElement, className: cn('flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm in-data-[platform=mobile]:mb-0.5 in-data-[platform=mobile]:text-base', disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer', selected && !disabled && 'bg-accent text-accent-foreground', selected && disabled && 'bg-accent/50'), onClick: () => {
|
|
1252
|
+
if (disabled)
|
|
1253
|
+
return;
|
|
1254
|
+
setHighlightedIndex(index);
|
|
1255
|
+
selectOptionAndCleanUp(option);
|
|
1256
|
+
}, onMouseEnter: () => setHighlightedIndex(index), children: [agent ? (_jsx("span", { "aria-hidden": true, className: "contents", children: _jsx(AgentAvatar, { size: "sm", picture: agentPicture, placeholder: _jsx(Bot, { "aria-hidden": true, className: "size-3.5" }), placeholderClassName: "bg-primary/10 text-primary", badge: providerBadge ? (_jsx(ProviderBadge, { type: providerBadge.type, label: providerBadge.label })) : null }) })) : (_jsxs(Avatar, { size: "sm", "aria-hidden": true, children: [avatarUrl ? _jsx(AvatarImage, { src: avatarUrl, alt: "" }) : null, _jsx(AvatarFallback, { className: cn(all && 'bg-primary/10 text-primary'), children: all ? (_jsx(Users, { "aria-hidden": true, className: "size-3.5" })) : (mentionInitials(candidate.displayName)) })] })), _jsxs("span", { className: "min-w-0 flex-1 truncate", children: [_jsxs("span", { className: "block truncate", children: [candidate.displayName, agent && candidate.role ? (_jsxs("span", { className: "text-muted-foreground", children: [" \u00B7 ", candidate.role] })) : null] }), subtitle ? (_jsx("span", { className: "block truncate text-xs text-muted-foreground", children: subtitle })) : null] })] }, option.key));
|
|
1257
|
+
}), loadingMore ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", role: "status", children: "Loading\u2026" })) : null] })) }), document.body);
|
|
1258
|
+
} }));
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Leading `/query` at the start of the message (no whitespace yet). Refused while a
|
|
1262
|
+
* `CycleTriggerNode` is already present — one `/trigger` per message.
|
|
1263
|
+
*/
|
|
1264
|
+
const $matchLeadingSlashQuery = (text) => {
|
|
1265
|
+
if ($nodesOfType(CycleTriggerNode).length > 0)
|
|
1266
|
+
return null;
|
|
1267
|
+
const match = /^\/([^\s]*)$/.exec(text);
|
|
1268
|
+
if (!match)
|
|
1269
|
+
return null;
|
|
1270
|
+
const selection = $getSelection();
|
|
1271
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed())
|
|
1272
|
+
return null;
|
|
1273
|
+
const anchorNode = selection.anchor.getNode();
|
|
1274
|
+
if (!$isTextNode(anchorNode) || !anchorNode.isSimpleText())
|
|
1275
|
+
return null;
|
|
1276
|
+
if (anchorNode.getPreviousSibling() !== null)
|
|
1277
|
+
return null;
|
|
1278
|
+
const paragraph = anchorNode.getParent();
|
|
1279
|
+
if (!$isElementNode(paragraph))
|
|
1280
|
+
return null;
|
|
1281
|
+
const first = $getRoot().getFirstChild();
|
|
1282
|
+
if (!first || paragraph.getKey() !== first.getKey())
|
|
1283
|
+
return null;
|
|
1284
|
+
return {
|
|
1285
|
+
matchingString: match[1] ?? '',
|
|
1286
|
+
replaceableString: match[0],
|
|
1287
|
+
};
|
|
1288
|
+
};
|
|
1289
|
+
function SlashCommandsPlugin({ searchSlashCommands, onSelectCommand, onSlashTyped, labels, }) {
|
|
1290
|
+
const [editor] = useLexicalComposerContext();
|
|
1291
|
+
const [options, setOptions] = React.useState([]);
|
|
1292
|
+
const [activeQuery, setActiveQuery] = React.useState(null);
|
|
1293
|
+
const triggerFn = React.useCallback((text, triggerEditor) => {
|
|
1294
|
+
if (lastUpdateMovedCaretOnly.get(triggerEditor))
|
|
1295
|
+
return null;
|
|
1296
|
+
const match = $matchLeadingSlashQuery(text);
|
|
1297
|
+
if (!match)
|
|
1298
|
+
return null;
|
|
1299
|
+
return {
|
|
1300
|
+
leadOffset: 0,
|
|
1301
|
+
matchingString: match.matchingString,
|
|
1302
|
+
replaceableString: match.replaceableString,
|
|
1303
|
+
};
|
|
1304
|
+
}, []);
|
|
1305
|
+
const onQueryChange = React.useCallback((query) => {
|
|
1306
|
+
setActiveQuery(query);
|
|
1307
|
+
if (query === null || !searchSlashCommands) {
|
|
1308
|
+
setOptions([]);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (onSlashTyped) {
|
|
1312
|
+
// The host handles `/query` itself (opens its own picker): drop the typed text, no inline menu.
|
|
1313
|
+
const typed = `/${query}`;
|
|
1314
|
+
setOptions([]);
|
|
1315
|
+
setActiveQuery(null);
|
|
1316
|
+
editor.update(() => {
|
|
1317
|
+
const selection = $getSelection();
|
|
1318
|
+
if (!$isRangeSelection(selection))
|
|
1319
|
+
return;
|
|
1320
|
+
const node = selection.anchor.getNode();
|
|
1321
|
+
if (!$isTextNode(node))
|
|
1322
|
+
return;
|
|
1323
|
+
// Only the `/query` that ends at the caret: a slash typed before a prompt keeps the prompt.
|
|
1324
|
+
const end = selection.anchor.offset;
|
|
1325
|
+
const start = end - typed.length;
|
|
1326
|
+
if (start < 0 || node.getTextContent().slice(start, end) !== typed)
|
|
1327
|
+
return;
|
|
1328
|
+
if (node.getTextContentSize() === typed.length)
|
|
1329
|
+
node.remove();
|
|
1330
|
+
else
|
|
1331
|
+
node.spliceText(start, typed.length, '', true);
|
|
1332
|
+
});
|
|
1333
|
+
onSlashTyped(query);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const apply = (candidates) => {
|
|
1337
|
+
// Cycles render above commands, so sort (stable) to keep keyboard order = visual order.
|
|
1338
|
+
const rank = (c) => (c.group === 'commands' ? 1 : 0);
|
|
1339
|
+
const ordered = [...candidates].sort((a, b) => rank(a) - rank(b));
|
|
1340
|
+
setOptions(ordered.map((candidate) => new SlashMenuOption(candidate)));
|
|
1341
|
+
};
|
|
1342
|
+
const result = searchSlashCommands(query);
|
|
1343
|
+
if (result instanceof Promise)
|
|
1344
|
+
void result.then(apply);
|
|
1345
|
+
else
|
|
1346
|
+
apply(result);
|
|
1347
|
+
}, [editor, searchSlashCommands, onSlashTyped]);
|
|
1348
|
+
const onSelectOption = React.useCallback((selectedOption, nodeToReplace, closeMenu) => {
|
|
1349
|
+
const { cycleTriggerName, insertText } = selectedOption.candidate;
|
|
1350
|
+
onSelectCommand?.(selectedOption.candidate);
|
|
1351
|
+
editor.update(() => {
|
|
1352
|
+
if (cycleTriggerName) {
|
|
1353
|
+
const trigger = $createCycleTriggerNode(cycleTriggerName);
|
|
1354
|
+
const space = $createTextNode(' ');
|
|
1355
|
+
if (nodeToReplace)
|
|
1356
|
+
nodeToReplace.replace(trigger);
|
|
1357
|
+
else {
|
|
1358
|
+
const root = $getRoot();
|
|
1359
|
+
let paragraph = root.getFirstChild();
|
|
1360
|
+
if (!paragraph) {
|
|
1361
|
+
paragraph = $createParagraphNode();
|
|
1362
|
+
root.append(paragraph);
|
|
1363
|
+
}
|
|
1364
|
+
if ($isElementNode(paragraph))
|
|
1365
|
+
paragraph.append(trigger);
|
|
1366
|
+
}
|
|
1367
|
+
trigger.insertAfter(space);
|
|
1368
|
+
space.select(1, 1);
|
|
1369
|
+
}
|
|
1370
|
+
else if (typeof insertText === 'string') {
|
|
1371
|
+
// Trailing space so the next word can be typed immediately (same as /trigger).
|
|
1372
|
+
const withSpace = insertText.endsWith(' ') ? insertText : `${insertText} `;
|
|
1373
|
+
const text = $createTextNode(withSpace);
|
|
1374
|
+
if (nodeToReplace)
|
|
1375
|
+
nodeToReplace.replace(text);
|
|
1376
|
+
text.select(withSpace.length, withSpace.length);
|
|
1377
|
+
}
|
|
1378
|
+
else if (nodeToReplace) {
|
|
1379
|
+
nodeToReplace.remove();
|
|
1380
|
+
}
|
|
1381
|
+
closeMenu();
|
|
1382
|
+
});
|
|
1383
|
+
}, [editor, onSelectCommand]);
|
|
1384
|
+
if (!searchSlashCommands)
|
|
1385
|
+
return null;
|
|
1386
|
+
return (_jsx(LexicalTypeaheadMenuPlugin, { onQueryChange: onQueryChange, onSelectOption: onSelectOption, triggerFn: triggerFn, options: options,
|
|
1387
|
+
// Above EnterSubmitPlugin (BEFORE_EDITOR) so Enter/Tab complete instead of sending.
|
|
1388
|
+
commandPriority: COMMAND_PRIORITY_HIGH, anchorClassName: TYPEAHEAD_ANCHOR_CLASS, menuRenderFn: (anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
|
1389
|
+
// Keep the empty-state panel while `/` matches, even with zero candidates (D16).
|
|
1390
|
+
if (!anchorElementRef.current || activeQuery === null)
|
|
1391
|
+
return null;
|
|
1392
|
+
return ReactDOM.createPortal(_jsx(TypeaheadMenuAboveCaret, { anchor: anchorElementRef.current, dataAttr: "data-slash-menu", className: "w-[340px] min-w-[340px] max-w-[calc(100vw-2rem)] rounded-xl p-1.5", optionCount: options.length, selectedIndex: selectedIndex, setHighlightedIndex: setHighlightedIndex, children: options.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(SlashGroupHeader, { label: labels.slashTitle, first: true }), _jsx("div", { className: "px-2 py-2 text-sm text-muted-foreground", children: labels.slashEmpty })] })) : (options.map((option, index) => {
|
|
1393
|
+
const selected = selectedIndex === index;
|
|
1394
|
+
const group = option.candidate.group;
|
|
1395
|
+
const startsGroup = index === 0 || options[index - 1]?.candidate.group !== group;
|
|
1396
|
+
const title = group === 'cycles'
|
|
1397
|
+
? labels.slashCyclesTitle
|
|
1398
|
+
: group === 'commands'
|
|
1399
|
+
? labels.slashCommandsTitle
|
|
1400
|
+
: labels.slashTitle;
|
|
1401
|
+
return (_jsxs(React.Fragment, { children: [startsGroup ? _jsx(SlashGroupHeader, { label: title, first: index === 0 }) : null, _jsxs("button", { id: `slash-typeahead-item-${index}`, type: "button", role: "option", tabIndex: -1, "aria-selected": selected,
|
|
1402
|
+
// Lexical scrolls the highlighted row into the max-h menu via this ref.
|
|
1403
|
+
ref: option.setRefElement, className: cn('relative isolate flex w-full flex-col rounded-lg p-2 text-start text-sm in-data-[platform=mobile]:mb-0.5 in-data-[platform=mobile]:text-base', selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'), onClick: () => {
|
|
1404
|
+
setHighlightedIndex(index);
|
|
1405
|
+
selectOptionAndCleanUp(option);
|
|
1406
|
+
}, onMouseEnter: () => setHighlightedIndex(index), children: [option.candidate.emphasis ? _jsx(SlashEmphasis, {}) : null, _jsxs("span", { children: ["/", option.candidate.name] }), _jsx("span", { className: "text-muted-foreground", children: option.candidate.description })] })] }, option.key));
|
|
1407
|
+
})) }), document.body);
|
|
1408
|
+
} }));
|
|
1409
|
+
}
|
|
1410
|
+
/** A tinted wash with a light band sweeping across it; still (tint only) under reduced motion. */
|
|
1411
|
+
function SlashEmphasis() {
|
|
1412
|
+
const reduceMotion = useReducedMotion();
|
|
1413
|
+
return (_jsx(motion.span, { "aria-hidden": true, "data-slot": "slash-emphasis", className: "pointer-events-none absolute inset-0 -z-10 rounded-lg bg-primary/8", style: {
|
|
1414
|
+
backgroundImage: 'linear-gradient(100deg, transparent 30%, color-mix(in oklab, var(--primary) 22%, transparent) 50%, transparent 70%)',
|
|
1415
|
+
backgroundSize: '220% 100%',
|
|
1416
|
+
backgroundRepeat: 'no-repeat',
|
|
1417
|
+
}, initial: { backgroundPosition: '160% 0' }, animate: reduceMotion === true ? undefined : { backgroundPosition: ['160% 0', '-60% 0'] }, transition: { ...loops.shimmer, duration: 1.8 } }));
|
|
1418
|
+
}
|
|
1419
|
+
function SlashGroupHeader({ label, first }) {
|
|
1420
|
+
return (_jsxs("div", { className: cn('flex items-center gap-2.5 px-2 pb-1 text-sm text-muted-foreground', first ? 'pt-1.5' : 'pt-3'), children: [_jsx("span", { children: label }), _jsx("span", { className: "h-px flex-1 bg-border", "aria-hidden": true })] }));
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* `:` shortcode typeahead (Slack-style). Host filters via `searchEmojis`; selecting inserts the
|
|
1424
|
+
* native glyph and closes the menu. Bare `:` alone does not open (minLength 1) so Enter still
|
|
1425
|
+
* sends. Underscores, dashes and `+` stay inside the query (`:man_beard`, `:+1`).
|
|
1426
|
+
*/
|
|
1427
|
+
function EmojiTypeaheadPlugin({ searchEmojis, labels, onEmojiUsed, }) {
|
|
1428
|
+
const [editor] = useLexicalComposerContext();
|
|
1429
|
+
const onEmojiUsedRef = useLatestRef(onEmojiUsed);
|
|
1430
|
+
const [options, setOptions] = React.useState([]);
|
|
1431
|
+
const [activeQuery, setActiveQuery] = React.useState(null);
|
|
1432
|
+
const requestIdRef = React.useRef(0);
|
|
1433
|
+
const emojiTriggerMatch = useBasicTypeaheadTriggerMatch(':', {
|
|
1434
|
+
minLength: 1,
|
|
1435
|
+
punctuation: EMOJI_TYPEAHEAD_PUNCTUATION,
|
|
1436
|
+
});
|
|
1437
|
+
const checkForTriggerMatch = useTypeaheadOutsideCode(emojiTriggerMatch);
|
|
1438
|
+
const onQueryChange = React.useCallback((query) => {
|
|
1439
|
+
setActiveQuery(query);
|
|
1440
|
+
if (query === null || !searchEmojis) {
|
|
1441
|
+
requestIdRef.current += 1;
|
|
1442
|
+
setOptions([]);
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
const requestId = ++requestIdRef.current;
|
|
1446
|
+
const apply = (candidates) => {
|
|
1447
|
+
if (requestId !== requestIdRef.current)
|
|
1448
|
+
return;
|
|
1449
|
+
setOptions(candidates.map((candidate) => new EmojiMenuOption(candidate)));
|
|
1450
|
+
};
|
|
1451
|
+
const result = searchEmojis(query);
|
|
1452
|
+
if (result instanceof Promise)
|
|
1453
|
+
void result.then(apply).catch(() => apply([]));
|
|
1454
|
+
else
|
|
1455
|
+
apply(result);
|
|
1456
|
+
}, [searchEmojis]);
|
|
1457
|
+
const onSelectOption = React.useCallback((selectedOption, nodeToReplace, closeMenu) => {
|
|
1458
|
+
const { char, shortcode, imageUrl } = selectedOption.candidate;
|
|
1459
|
+
editor.update(() => {
|
|
1460
|
+
if (imageUrl) {
|
|
1461
|
+
const emoji = $createCustomEmojiNode(shortcode, imageUrl);
|
|
1462
|
+
if (nodeToReplace)
|
|
1463
|
+
nodeToReplace.replace(emoji);
|
|
1464
|
+
else
|
|
1465
|
+
$insertNodes([emoji]);
|
|
1466
|
+
emoji.selectNext();
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
const text = $createTextNode(char);
|
|
1470
|
+
if (nodeToReplace)
|
|
1471
|
+
nodeToReplace.replace(text);
|
|
1472
|
+
else
|
|
1473
|
+
$insertNodes([text]);
|
|
1474
|
+
text.selectNext();
|
|
1475
|
+
}
|
|
1476
|
+
closeMenu();
|
|
1477
|
+
}, {
|
|
1478
|
+
onUpdate: () => reportEmojiUse(onEmojiUsedRef.current, {
|
|
1479
|
+
source: 'typeahead',
|
|
1480
|
+
kind: imageUrl ? 'custom' : 'unicode',
|
|
1481
|
+
emoji: imageUrl ? shortcode : char,
|
|
1482
|
+
}),
|
|
1483
|
+
});
|
|
1484
|
+
}, [editor, onEmojiUsedRef]);
|
|
1485
|
+
if (!searchEmojis)
|
|
1486
|
+
return null;
|
|
1487
|
+
return (_jsx(LexicalTypeaheadMenuPlugin, { onQueryChange: onQueryChange, onSelectOption: onSelectOption, triggerFn: checkForTriggerMatch, options: options,
|
|
1488
|
+
// Above EnterSubmitPlugin (BEFORE_EDITOR) so Enter/Tab complete instead of sending.
|
|
1489
|
+
commandPriority: COMMAND_PRIORITY_HIGH, anchorClassName: TYPEAHEAD_ANCHOR_CLASS, menuRenderFn: (anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
|
1490
|
+
if (!anchorElementRef.current || activeQuery === null)
|
|
1491
|
+
return null;
|
|
1492
|
+
return ReactDOM.createPortal(_jsx(TypeaheadMenuAboveCaret, { anchor: anchorElementRef.current, dataAttr: "data-emoji-menu", className: "w-64 min-w-56", optionCount: options.length, selectedIndex: selectedIndex, setHighlightedIndex: setHighlightedIndex, children: options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", role: "status", children: labels.emojiEmpty })) : (options.map((option, index) => {
|
|
1493
|
+
const selected = selectedIndex === index;
|
|
1494
|
+
return (_jsxs("button", { id: `emoji-typeahead-item-${index}`, type: "button", role: "option", tabIndex: -1, "aria-selected": selected, "aria-label": `:${option.candidate.shortcode}:`, ref: option.setRefElement, className: cn('flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-start text-sm in-data-[platform=mobile]:mb-0.5 in-data-[platform=mobile]:text-base', selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'), onClick: () => {
|
|
1495
|
+
setHighlightedIndex(index);
|
|
1496
|
+
selectOptionAndCleanUp(option);
|
|
1497
|
+
}, onMouseEnter: () => setHighlightedIndex(index), children: [_jsx("span", { "aria-hidden": true, className: "inline-flex size-5 shrink-0 items-center justify-center", children: option.candidate.imageUrl ? (_jsx("img", { src: option.candidate.imageUrl, alt: "", className: "size-5 object-contain" })) : (_jsx("span", { className: "text-base leading-none", children: option.candidate.char })) }), _jsxs("span", { className: "min-w-0 flex-1 truncate text-muted-foreground", children: [":", option.candidate.shortcode, ":"] })] }, option.key));
|
|
1498
|
+
})) }), document.body);
|
|
1499
|
+
} }));
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* `#` stream chip typeahead (Slack-style channel chips). Host filters via `searchStreams`;
|
|
1503
|
+
* selecting inserts a `StreamChipNode` and a trailing space. Bare `#` alone opens (minLength 0).
|
|
1504
|
+
*/
|
|
1505
|
+
function StreamChipsTypeaheadPlugin({ searchStreams, labels, }) {
|
|
1506
|
+
const [editor] = useLexicalComposerContext();
|
|
1507
|
+
const [options, setOptions] = React.useState([]);
|
|
1508
|
+
const [activeQuery, setActiveQuery] = React.useState(null);
|
|
1509
|
+
const requestIdRef = React.useRef(0);
|
|
1510
|
+
// '#' on purpose (chips display '~'): '~' collides with '~/path', '~~strike~~' and '~5'. #1166 changed it; do not repeat.
|
|
1511
|
+
const streamTriggerMatch = useBasicTypeaheadTriggerMatch('#', { minLength: 0 });
|
|
1512
|
+
const checkForTriggerMatch = useTypeaheadOutsideCode(streamTriggerMatch);
|
|
1513
|
+
const onQueryChange = React.useCallback((query) => {
|
|
1514
|
+
setActiveQuery(query);
|
|
1515
|
+
if (query === null || !searchStreams) {
|
|
1516
|
+
requestIdRef.current += 1;
|
|
1517
|
+
setOptions([]);
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const requestId = ++requestIdRef.current;
|
|
1521
|
+
const apply = (candidates) => {
|
|
1522
|
+
if (requestId !== requestIdRef.current)
|
|
1523
|
+
return;
|
|
1524
|
+
setOptions(candidates.map((candidate) => new StreamMenuOption(candidate)));
|
|
1525
|
+
};
|
|
1526
|
+
const result = searchStreams(query);
|
|
1527
|
+
if (result instanceof Promise)
|
|
1528
|
+
void result.then(apply).catch(() => apply([]));
|
|
1529
|
+
else
|
|
1530
|
+
apply(result);
|
|
1531
|
+
}, [searchStreams]);
|
|
1532
|
+
const onSelectOption = React.useCallback((selectedOption, nodeToReplace, closeMenu) => {
|
|
1533
|
+
editor.update(() => {
|
|
1534
|
+
const { streamId, title, visibility } = selectedOption.candidate;
|
|
1535
|
+
const node = $createStreamChipNode(streamId, title, visibility);
|
|
1536
|
+
if (nodeToReplace)
|
|
1537
|
+
nodeToReplace.replace(node);
|
|
1538
|
+
else
|
|
1539
|
+
$insertNodes([node]);
|
|
1540
|
+
// Trailing space so the next word can be typed immediately (same as @mention / /trigger).
|
|
1541
|
+
const space = $createTextNode(' ');
|
|
1542
|
+
node.insertAfter(space);
|
|
1543
|
+
space.select(1, 1);
|
|
1544
|
+
closeMenu();
|
|
1545
|
+
});
|
|
1546
|
+
}, [editor]);
|
|
1547
|
+
if (!searchStreams)
|
|
1548
|
+
return null;
|
|
1549
|
+
return (_jsx(LexicalTypeaheadMenuPlugin, { onQueryChange: onQueryChange, onSelectOption: onSelectOption, triggerFn: checkForTriggerMatch, options: options,
|
|
1550
|
+
// Above EnterSubmitPlugin (NORMAL) so Enter/Tab complete instead of sending.
|
|
1551
|
+
commandPriority: COMMAND_PRIORITY_HIGH, anchorClassName: TYPEAHEAD_ANCHOR_CLASS, menuRenderFn: (anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
|
1552
|
+
if (!anchorElementRef.current || activeQuery === null)
|
|
1553
|
+
return null;
|
|
1554
|
+
return ReactDOM.createPortal(_jsx(TypeaheadMenuAboveCaret, { anchor: anchorElementRef.current, dataAttr: "data-stream-menu", className: "w-64 min-w-56", optionCount: options.length, selectedIndex: selectedIndex, setHighlightedIndex: setHighlightedIndex, children: options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", role: "status", children: labels.streamEmpty })) : (_jsx(TooltipProvider, { children: options.map((option, index) => {
|
|
1555
|
+
const selected = selectedIndex === index;
|
|
1556
|
+
const privateStream = option.candidate.visibility === 'private';
|
|
1557
|
+
const visibilityLabel = privateStream ? labels.streamPrivate : labels.streamPublic;
|
|
1558
|
+
return (_jsxs(Tooltip, { children: [_jsxs(TooltipTrigger, { render: _jsx("button", { id: `stream-typeahead-item-${index}`, type: "button", role: "option", tabIndex: -1, "aria-selected": selected, "aria-label": `~${option.candidate.title}, ${visibilityLabel}`, ref: option.setRefElement, className: cn('flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-start text-sm in-data-[platform=mobile]:mb-0.5 in-data-[platform=mobile]:text-base', selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'), onClick: () => {
|
|
1559
|
+
setHighlightedIndex(index);
|
|
1560
|
+
selectOptionAndCleanUp(option);
|
|
1561
|
+
}, onMouseEnter: () => setHighlightedIndex(index) }), children: [privateStream ? (_jsx(Lock, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true })) : (_jsx("span", { className: "w-4 shrink-0 text-center text-muted-foreground", "aria-hidden": true, children: "~" })), _jsx("span", { className: "min-w-0 flex-1 truncate", children: option.candidate.title })] }), _jsx(TooltipContent, { children: _jsxs("span", { className: "inline-flex items-center gap-1.5", children: [privateStream ? (_jsx(Lock, { className: "size-3.5 shrink-0", "aria-hidden": true })) : null, visibilityLabel] }) })] }, option.key));
|
|
1562
|
+
}) })) }), document.body);
|
|
1563
|
+
} }));
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Expands a completed `:shortcode:` (closing colon typed) into a native glyph or a
|
|
1567
|
+
* `CustomEmojiNode`. Runs as a TextNode transform so it works without picking from the
|
|
1568
|
+
* typeahead. Host resolves via `resolveEmojiShortcode`; unknown codes are left alone.
|
|
1569
|
+
*/
|
|
1570
|
+
function EmojiShortcodeReplacePlugin({ resolveEmojiShortcode, onEmojiUsed, }) {
|
|
1571
|
+
const [editor] = useLexicalComposerContext();
|
|
1572
|
+
const onEmojiUsedRef = useLatestRef(onEmojiUsed);
|
|
1573
|
+
const typedRef = useTypedEditFlag(editor, Boolean(onEmojiUsed));
|
|
1574
|
+
const pendingRef = React.useRef([]);
|
|
1575
|
+
// Report after the update has committed, never from inside the transform.
|
|
1576
|
+
React.useEffect(() => editor.registerUpdateListener(() => {
|
|
1577
|
+
if (pendingRef.current.length === 0)
|
|
1578
|
+
return;
|
|
1579
|
+
const uses = pendingRef.current;
|
|
1580
|
+
pendingRef.current = [];
|
|
1581
|
+
for (const use of uses)
|
|
1582
|
+
reportEmojiUse(onEmojiUsedRef.current, use);
|
|
1583
|
+
}), [editor, onEmojiUsedRef]);
|
|
1584
|
+
React.useEffect(() => {
|
|
1585
|
+
if (!resolveEmojiShortcode)
|
|
1586
|
+
return;
|
|
1587
|
+
const pattern = /:([a-zA-Z0-9_+-]+):/;
|
|
1588
|
+
return editor.registerNodeTransform(TextNode, (node) => {
|
|
1589
|
+
if (!node.isSimpleText())
|
|
1590
|
+
return;
|
|
1591
|
+
// Code blocks are source text — never expand :shortcodes: inside them.
|
|
1592
|
+
if ($findMatchingParent(node, $isCodeNode))
|
|
1593
|
+
return;
|
|
1594
|
+
const text = node.getTextContent();
|
|
1595
|
+
const match = pattern.exec(text);
|
|
1596
|
+
if (!match || match.index === undefined)
|
|
1597
|
+
return;
|
|
1598
|
+
const shortcode = match[1];
|
|
1599
|
+
if (!shortcode)
|
|
1600
|
+
return;
|
|
1601
|
+
const resolved = resolveEmojiShortcode(shortcode);
|
|
1602
|
+
if (!resolved)
|
|
1603
|
+
return;
|
|
1604
|
+
const start = match.index;
|
|
1605
|
+
const end = start + match[0].length;
|
|
1606
|
+
// Counts only when this keystroke typed the closing colon of this very shortcode: the
|
|
1607
|
+
// transform also runs for paste, undo, restored drafts and programmatic edits.
|
|
1608
|
+
const selection = $getSelection();
|
|
1609
|
+
const typedClosingColon = typedRef.current &&
|
|
1610
|
+
$isRangeSelection(selection) &&
|
|
1611
|
+
selection.isCollapsed() &&
|
|
1612
|
+
selection.anchor.type === 'text' &&
|
|
1613
|
+
selection.anchor.key === node.getKey() &&
|
|
1614
|
+
selection.anchor.offset === end;
|
|
1615
|
+
let target = node;
|
|
1616
|
+
if (start > 0) {
|
|
1617
|
+
const parts = node.splitText(start);
|
|
1618
|
+
target = parts[1] ?? node;
|
|
1619
|
+
}
|
|
1620
|
+
if (end - start < target.getTextContentSize()) {
|
|
1621
|
+
target.splitText(end - start);
|
|
1622
|
+
}
|
|
1623
|
+
if (typeof resolved === 'string') {
|
|
1624
|
+
target.replace($createTextNode(resolved));
|
|
1625
|
+
}
|
|
1626
|
+
else {
|
|
1627
|
+
target.replace($createCustomEmojiNode(resolved.name, resolved.imageUrl));
|
|
1628
|
+
}
|
|
1629
|
+
if (typedClosingColon) {
|
|
1630
|
+
pendingRef.current.push({
|
|
1631
|
+
source: 'typed_shortcode',
|
|
1632
|
+
kind: typeof resolved === 'string' ? 'unicode' : 'custom',
|
|
1633
|
+
emoji: typeof resolved === 'string' ? resolved : resolved.name,
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
}, [editor, resolveEmojiShortcode, typedRef]);
|
|
1638
|
+
return null;
|
|
1639
|
+
}
|
|
1640
|
+
/** Keeps the newest callback without re-registering editor listeners when its identity changes. */
|
|
1641
|
+
function useLatestRef(value) {
|
|
1642
|
+
const ref = React.useRef(value);
|
|
1643
|
+
React.useEffect(() => {
|
|
1644
|
+
ref.current = value;
|
|
1645
|
+
});
|
|
1646
|
+
return ref;
|
|
1647
|
+
}
|
|
1648
|
+
/** A host callback must never break editing. */
|
|
1649
|
+
function reportEmojiUse(callback, use) {
|
|
1650
|
+
try {
|
|
1651
|
+
callback?.(use);
|
|
1652
|
+
}
|
|
1653
|
+
catch {
|
|
1654
|
+
// Host reporting is best effort.
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/** `beforeinput` types a person produces by typing (including IME composition), not paste, drop or history. */
|
|
1658
|
+
const TYPED_INPUT_TYPES = new Set(['insertText', 'insertCompositionText', 'insertFromComposition']);
|
|
1659
|
+
/**
|
|
1660
|
+
* True while a typing `beforeinput` is being applied: set before Lexical handles the event and
|
|
1661
|
+
* cleared once its updates have run. Edits that did not come from the keyboard never set it.
|
|
1662
|
+
*/
|
|
1663
|
+
function useTypedEditFlag(editor, enabled) {
|
|
1664
|
+
const typedRef = React.useRef(false);
|
|
1665
|
+
React.useEffect(() => {
|
|
1666
|
+
if (!enabled)
|
|
1667
|
+
return;
|
|
1668
|
+
let timer;
|
|
1669
|
+
let attached = null;
|
|
1670
|
+
const onBeforeInput = (event) => {
|
|
1671
|
+
if (!TYPED_INPUT_TYPES.has(event.inputType))
|
|
1672
|
+
return;
|
|
1673
|
+
typedRef.current = true;
|
|
1674
|
+
window.clearTimeout(timer);
|
|
1675
|
+
timer = window.setTimeout(() => {
|
|
1676
|
+
typedRef.current = false;
|
|
1677
|
+
}, 0);
|
|
1678
|
+
};
|
|
1679
|
+
const unregister = editor.registerRootListener((root, previous) => {
|
|
1680
|
+
previous?.removeEventListener('beforeinput', onBeforeInput, true);
|
|
1681
|
+
root?.addEventListener('beforeinput', onBeforeInput, true);
|
|
1682
|
+
attached = root;
|
|
1683
|
+
});
|
|
1684
|
+
return () => {
|
|
1685
|
+
unregister();
|
|
1686
|
+
attached?.removeEventListener('beforeinput', onBeforeInput, true);
|
|
1687
|
+
window.clearTimeout(timer);
|
|
1688
|
+
typedRef.current = false;
|
|
1689
|
+
};
|
|
1690
|
+
}, [editor, enabled]);
|
|
1691
|
+
return typedRef;
|
|
1692
|
+
}
|
|
1693
|
+
/** `CycleTriggerNode` chip look (`bg-secondary px-1 rounded`); font size and weight inherit from the surrounding text. */
|
|
1694
|
+
const SLASH_CHIP_STYLE = 'background-color: var(--secondary); color: var(--secondary-foreground); border-radius: 4px; padding: 0 4px;';
|
|
1695
|
+
/**
|
|
1696
|
+
* Paints the leading `/name` of plain-text slash commands (e.g. `/giphy`) like a cycle trigger
|
|
1697
|
+
* chip while the text stays ordinary text, so the prompt after it and `plainTextOf` are
|
|
1698
|
+
* untouched. Only the exact leading token carries the style; anything else is cleared.
|
|
1699
|
+
*/
|
|
1700
|
+
function SlashCommandChipPlugin({ commands }) {
|
|
1701
|
+
const [editor] = useLexicalComposerContext();
|
|
1702
|
+
React.useEffect(() => {
|
|
1703
|
+
if (!commands || commands.length === 0)
|
|
1704
|
+
return;
|
|
1705
|
+
return editor.registerNodeTransform(TextNode, (node) => {
|
|
1706
|
+
if (!node.isSimpleText())
|
|
1707
|
+
return;
|
|
1708
|
+
const text = node.getTextContent();
|
|
1709
|
+
const parent = node.getParent();
|
|
1710
|
+
const isLead = node.getPreviousSibling() === null &&
|
|
1711
|
+
$isElementNode(parent) &&
|
|
1712
|
+
parent.getKey() === $getRoot().getFirstChild()?.getKey();
|
|
1713
|
+
const token = isLead
|
|
1714
|
+
? commands.find((name) => {
|
|
1715
|
+
const lead = `/${name}`;
|
|
1716
|
+
return (text.startsWith(lead) &&
|
|
1717
|
+
(text.length === lead.length || /\s/.test(text[lead.length] ?? '')));
|
|
1718
|
+
})
|
|
1719
|
+
: undefined;
|
|
1720
|
+
if (!token) {
|
|
1721
|
+
if (node.getStyle() === SLASH_CHIP_STYLE)
|
|
1722
|
+
node.setStyle('');
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const length = token.length + 1;
|
|
1726
|
+
let target = node;
|
|
1727
|
+
if (text.length > length) {
|
|
1728
|
+
const [head, rest] = node.splitText(length);
|
|
1729
|
+
target = head ?? node;
|
|
1730
|
+
if (rest && rest.getStyle() !== '')
|
|
1731
|
+
rest.setStyle('');
|
|
1732
|
+
}
|
|
1733
|
+
if (target.getStyle() !== SLASH_CHIP_STYLE)
|
|
1734
|
+
target.setStyle(SLASH_CHIP_STYLE);
|
|
1735
|
+
});
|
|
1736
|
+
}, [editor, commands]);
|
|
1737
|
+
return null;
|
|
1738
|
+
}
|
|
1739
|
+
const DEFAULT_DOCUMENT_LABELS = {
|
|
1740
|
+
heading2: 'Heading',
|
|
1741
|
+
heading3: 'Subheading',
|
|
1742
|
+
image: 'Add image',
|
|
1743
|
+
imageAlt: 'Image description',
|
|
1744
|
+
imageAltPlaceholder: 'Describe the image',
|
|
1745
|
+
imageAltRequired: 'Add a description for this image',
|
|
1746
|
+
imageRemove: 'Remove image',
|
|
1747
|
+
imageUnavailable: 'Image unavailable',
|
|
1748
|
+
uploading: 'Uploading image',
|
|
1749
|
+
};
|
|
1750
|
+
const DocumentModeCtx = React.createContext(null);
|
|
1751
|
+
/** Block image that round-trips with contracts/streams/about-lexical.ts (`type: 'about-image'`). */
|
|
1752
|
+
export class DocumentImageNode extends DecoratorNode {
|
|
1753
|
+
__assetId;
|
|
1754
|
+
__alt;
|
|
1755
|
+
static getType() {
|
|
1756
|
+
return 'about-image';
|
|
1757
|
+
}
|
|
1758
|
+
static clone(node) {
|
|
1759
|
+
return new DocumentImageNode(node.__assetId, node.__alt, node.__key);
|
|
1760
|
+
}
|
|
1761
|
+
constructor(assetId, alt, key) {
|
|
1762
|
+
super(key);
|
|
1763
|
+
this.__assetId = assetId;
|
|
1764
|
+
this.__alt = alt;
|
|
1765
|
+
}
|
|
1766
|
+
createDOM(_config) {
|
|
1767
|
+
const div = document.createElement('div');
|
|
1768
|
+
div.className = 'my-2';
|
|
1769
|
+
return div;
|
|
1770
|
+
}
|
|
1771
|
+
updateDOM() {
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1775
|
+
static importJSON(serialized) {
|
|
1776
|
+
return new DocumentImageNode(String(serialized.assetId ?? ''), String(serialized.alt ?? ''));
|
|
1777
|
+
}
|
|
1778
|
+
exportJSON() {
|
|
1779
|
+
return { type: 'about-image', version: 1, assetId: this.__assetId, alt: this.__alt };
|
|
1780
|
+
}
|
|
1781
|
+
getAssetId() {
|
|
1782
|
+
return this.__assetId;
|
|
1783
|
+
}
|
|
1784
|
+
getAlt() {
|
|
1785
|
+
return this.__alt;
|
|
1786
|
+
}
|
|
1787
|
+
setAlt(alt) {
|
|
1788
|
+
const writable = this.getWritable();
|
|
1789
|
+
writable.__alt = alt;
|
|
1790
|
+
return writable;
|
|
1791
|
+
}
|
|
1792
|
+
isInline() {
|
|
1793
|
+
return false;
|
|
1794
|
+
}
|
|
1795
|
+
decorate() {
|
|
1796
|
+
return _jsx(DocumentImageView, { nodeKey: this.__key, assetId: this.__assetId, alt: this.__alt });
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
export const $isDocumentImageNode = (node) => node instanceof DocumentImageNode;
|
|
1800
|
+
/**
|
|
1801
|
+
* Keys typed in the description field must not reach Lexical's root listeners (Enter, Backspace,
|
|
1802
|
+
* undo). Marking the event handled keeps it bubbling, so React's root listener still sees it.
|
|
1803
|
+
*/
|
|
1804
|
+
const useIsolatedFromEditor = (ref) => {
|
|
1805
|
+
React.useEffect(() => {
|
|
1806
|
+
const element = ref.current;
|
|
1807
|
+
if (!element)
|
|
1808
|
+
return;
|
|
1809
|
+
const stop = (event) => stopLexicalPropagation(event);
|
|
1810
|
+
const events = ['keydown', 'keyup', 'keypress', 'beforeinput', 'input', 'paste', 'cut', 'copy'];
|
|
1811
|
+
for (const name of events)
|
|
1812
|
+
element.addEventListener(name, stop);
|
|
1813
|
+
return () => {
|
|
1814
|
+
for (const name of events)
|
|
1815
|
+
element.removeEventListener(name, stop);
|
|
1816
|
+
};
|
|
1817
|
+
}, [ref]);
|
|
1818
|
+
};
|
|
1819
|
+
function DocumentImageView({ nodeKey, assetId, alt, }) {
|
|
1820
|
+
const [editor] = useLexicalComposerContext();
|
|
1821
|
+
const ctx = React.useContext(DocumentModeCtx);
|
|
1822
|
+
const labels = ctx?.labels ?? DEFAULT_DOCUMENT_LABELS;
|
|
1823
|
+
const [failed, setFailed] = React.useState(false);
|
|
1824
|
+
const controlsRef = React.useRef(null);
|
|
1825
|
+
const altId = React.useId();
|
|
1826
|
+
const altInputRef = React.useRef(null);
|
|
1827
|
+
const [initialAlt] = React.useState(alt);
|
|
1828
|
+
useIsolatedFromEditor(controlsRef);
|
|
1829
|
+
// Uncontrolled so typing is never reset by the asynchronous Lexical commit (the caret would jump);
|
|
1830
|
+
// external changes (undo) are copied in.
|
|
1831
|
+
React.useEffect(() => {
|
|
1832
|
+
const input = altInputRef.current;
|
|
1833
|
+
if (input && input.value !== alt)
|
|
1834
|
+
input.value = alt;
|
|
1835
|
+
}, [alt]);
|
|
1836
|
+
const src = ctx?.options.resolveImageUrl(assetId) ?? null;
|
|
1837
|
+
const editable = editor.isEditable();
|
|
1838
|
+
const update = (change) => editor.update(() => {
|
|
1839
|
+
const node = $getNodeByKey(nodeKey);
|
|
1840
|
+
if ($isDocumentImageNode(node))
|
|
1841
|
+
change(node);
|
|
1842
|
+
});
|
|
1843
|
+
return (_jsxs("figure", { "data-slot": "document-image", className: "m-0 grid gap-1.5 rounded-md border p-2", children: [src && !failed ? (_jsx("img", { src: src, alt: alt, loading: "lazy", decoding: "async", onError: () => setFailed(true), className: "max-h-80 w-auto max-w-full rounded object-contain" })) : (_jsx("div", { className: "flex h-24 items-center justify-center rounded bg-muted text-xs text-muted-foreground", children: failed ? labels.imageUnavailable : null })), editable ? (_jsxs("div", { ref: controlsRef, className: "flex items-center gap-2", children: [_jsx(Label, { htmlFor: altId, className: "sr-only", children: labels.imageAlt }), _jsx(Input, { id: altId, ref: altInputRef, defaultValue: initialAlt, placeholder: labels.imageAltPlaceholder, "aria-invalid": alt.trim() === '' || undefined, "aria-describedby": alt.trim() === '' ? `${altId}-error` : undefined, onChange: (event) => update((node) => node.setAlt(event.target.value)) }), _jsx(Button, { type: "button", size: "icon-xs", variant: "ghost", "aria-label": labels.imageRemove, onClick: () => update((node) => node.remove()), children: _jsx(Trash, { "aria-hidden": true }) }), alt.trim() === '' ? (_jsx("span", { id: `${altId}-error`, className: "sr-only", children: labels.imageAltRequired })) : null] })) : null] }));
|
|
1844
|
+
}
|
|
1845
|
+
const $countDocumentImages = () => $nodesOfType(DocumentImageNode).length;
|
|
1846
|
+
/** Picks, pastes and drops become uploads; each finished upload becomes an image block at the caret. */
|
|
1847
|
+
function useDocumentImages(editor, options) {
|
|
1848
|
+
const [pending, setPending] = React.useState(0);
|
|
1849
|
+
const inFlight = React.useRef(0);
|
|
1850
|
+
const optionsRef = React.useRef(options);
|
|
1851
|
+
React.useLayoutEffect(() => {
|
|
1852
|
+
optionsRef.current = options;
|
|
1853
|
+
});
|
|
1854
|
+
React.useEffect(() => {
|
|
1855
|
+
optionsRef.current?.onUploadingChange?.(pending > 0);
|
|
1856
|
+
}, [pending]);
|
|
1857
|
+
const addFiles = React.useCallback(async (files) => {
|
|
1858
|
+
for (const file of files) {
|
|
1859
|
+
const current = optionsRef.current;
|
|
1860
|
+
if (!current)
|
|
1861
|
+
return;
|
|
1862
|
+
if (!current.acceptedImageTypes.includes(file.type)) {
|
|
1863
|
+
current.onReject?.(file, 'type');
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
const held = editor.getEditorState().read($countDocumentImages);
|
|
1867
|
+
if (current.maxImages !== undefined && held + inFlight.current >= current.maxImages) {
|
|
1868
|
+
current.onReject?.(file, 'limit');
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
inFlight.current += 1;
|
|
1872
|
+
setPending((count) => count + 1);
|
|
1873
|
+
try {
|
|
1874
|
+
const assetId = await current.uploadImage(file);
|
|
1875
|
+
if (!assetId)
|
|
1876
|
+
continue;
|
|
1877
|
+
// OnChangePlugin drops the first update of a never-focused (empty, selection-less)
|
|
1878
|
+
// editor, so give the state a selection before the insert that must be reported.
|
|
1879
|
+
editor.update(() => {
|
|
1880
|
+
if (!$getSelection())
|
|
1881
|
+
$getRoot().selectEnd();
|
|
1882
|
+
}, { discrete: true });
|
|
1883
|
+
editor.update(() => {
|
|
1884
|
+
const node = new DocumentImageNode(assetId, '');
|
|
1885
|
+
$insertNodeToNearestRoot(node);
|
|
1886
|
+
const next = node.getNextSibling();
|
|
1887
|
+
if ($isParagraphNode(next))
|
|
1888
|
+
next.selectStart();
|
|
1889
|
+
else
|
|
1890
|
+
node.insertAfter($createParagraphNode()).selectStart();
|
|
1891
|
+
}, { discrete: true });
|
|
1892
|
+
}
|
|
1893
|
+
finally {
|
|
1894
|
+
inFlight.current -= 1;
|
|
1895
|
+
setPending((count) => count - 1);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}, [editor]);
|
|
1899
|
+
React.useEffect(() => {
|
|
1900
|
+
if (!options)
|
|
1901
|
+
return;
|
|
1902
|
+
const imageFiles = (list) => Array.from(list ?? []).filter((file) => file.type.startsWith('image/'));
|
|
1903
|
+
const unregisterPaste = editor.registerCommand(PASTE_COMMAND, (event) => {
|
|
1904
|
+
if (!('clipboardData' in event) || !event.clipboardData)
|
|
1905
|
+
return false;
|
|
1906
|
+
const files = imageFiles(event.clipboardData.files);
|
|
1907
|
+
if (files.length === 0)
|
|
1908
|
+
return false;
|
|
1909
|
+
event.preventDefault();
|
|
1910
|
+
void addFiles(files);
|
|
1911
|
+
return true;
|
|
1912
|
+
}, COMMAND_PRIORITY_HIGH);
|
|
1913
|
+
const unregisterDrop = editor.registerCommand(DROP_COMMAND, (event) => {
|
|
1914
|
+
const files = imageFiles(event.dataTransfer?.files);
|
|
1915
|
+
if (files.length === 0)
|
|
1916
|
+
return false;
|
|
1917
|
+
event.preventDefault();
|
|
1918
|
+
void addFiles(files);
|
|
1919
|
+
return true;
|
|
1920
|
+
}, COMMAND_PRIORITY_HIGH);
|
|
1921
|
+
return () => {
|
|
1922
|
+
unregisterPaste();
|
|
1923
|
+
unregisterDrop();
|
|
1924
|
+
};
|
|
1925
|
+
}, [editor, options, addFiles]);
|
|
1926
|
+
return { addFiles, uploading: pending > 0 };
|
|
1927
|
+
}
|
|
1928
|
+
/** Turns the block holding the caret into a heading of `tag`, or back into a paragraph when it already is one. */
|
|
1929
|
+
const toggleHeading = (editor, tag) => editor.update(() => {
|
|
1930
|
+
const selection = $getSelection();
|
|
1931
|
+
if (!$isRangeSelection(selection))
|
|
1932
|
+
return;
|
|
1933
|
+
const top = selection.anchor.getNode().getTopLevelElement();
|
|
1934
|
+
if (!top || $isListNode(top) || (!$isParagraphNode(top) && !$isHeadingNode(top)))
|
|
1935
|
+
return;
|
|
1936
|
+
if ($isHeadingNode(top) && top.getTag() === tag)
|
|
1937
|
+
top.replace($createParagraphNode(), true);
|
|
1938
|
+
else
|
|
1939
|
+
top.replace($createHeadingNode(tag), true);
|
|
1940
|
+
});
|
|
1941
|
+
// ── Formatting: theme, selection state, toolbar, link dialog ────────────────
|
|
1942
|
+
/**
|
|
1943
|
+
* Only formats the `contracts/chat/adapters/lexical.ts` round-trip preserves are exposed:
|
|
1944
|
+
* bold, italic, strikethrough, inline code, bullet/numbered lists and links. Headings, quotes and
|
|
1945
|
+
* underline are deliberately absent because the message AST has no representation for them.
|
|
1946
|
+
*/
|
|
1947
|
+
const editorTheme = {
|
|
1948
|
+
// DirectionPlugin sets `dir` for bidi; `text-left` keeps lines left-aligned like Slack.
|
|
1949
|
+
paragraph: 'm-0 text-left',
|
|
1950
|
+
text: {
|
|
1951
|
+
bold: 'font-semibold',
|
|
1952
|
+
italic: 'italic',
|
|
1953
|
+
strikethrough: 'line-through',
|
|
1954
|
+
underline: 'underline',
|
|
1955
|
+
underlineStrikethrough: 'underline line-through',
|
|
1956
|
+
code: 'rounded bg-muted px-1 font-mono text-[0.9em]',
|
|
1957
|
+
},
|
|
1958
|
+
heading: {
|
|
1959
|
+
h2: 'mt-3 mb-1 text-lg font-semibold text-start',
|
|
1960
|
+
h3: 'mt-2 mb-1 text-base font-semibold text-start',
|
|
1961
|
+
},
|
|
1962
|
+
list: {
|
|
1963
|
+
// Markers follow `dir`, so the gutter is logical; `w-fit` keeps RTL markers beside the text.
|
|
1964
|
+
ul: 'my-1 w-fit max-w-full list-disc ps-6 text-start',
|
|
1965
|
+
ol: 'my-1 w-fit max-w-full list-decimal ps-6 text-start',
|
|
1966
|
+
listitem: 'my-0.5 text-start',
|
|
1967
|
+
nested: { listitem: 'list-none' },
|
|
1968
|
+
},
|
|
1969
|
+
link: 'text-primary underline underline-offset-2',
|
|
1970
|
+
code: 'my-1 block rounded bg-muted p-2 font-mono text-[0.9em]',
|
|
1971
|
+
};
|
|
1972
|
+
const DOCUMENT_MARKDOWN_TRANSFORMERS = [
|
|
1973
|
+
BOLD_ITALIC_STAR,
|
|
1974
|
+
BOLD_ITALIC_UNDERSCORE,
|
|
1975
|
+
BOLD_STAR,
|
|
1976
|
+
BOLD_UNDERSCORE,
|
|
1977
|
+
ITALIC_STAR,
|
|
1978
|
+
ITALIC_UNDERSCORE,
|
|
1979
|
+
INLINE_CODE,
|
|
1980
|
+
UNORDERED_LIST,
|
|
1981
|
+
ORDERED_LIST,
|
|
1982
|
+
LINK,
|
|
1983
|
+
];
|
|
1984
|
+
const MARKDOWN_TRANSFORMERS = [
|
|
1985
|
+
BOLD_ITALIC_STAR,
|
|
1986
|
+
BOLD_ITALIC_UNDERSCORE,
|
|
1987
|
+
BOLD_STAR,
|
|
1988
|
+
BOLD_UNDERSCORE,
|
|
1989
|
+
ITALIC_STAR,
|
|
1990
|
+
ITALIC_UNDERSCORE,
|
|
1991
|
+
STRIKETHROUGH,
|
|
1992
|
+
INLINE_CODE,
|
|
1993
|
+
CODE,
|
|
1994
|
+
UNORDERED_LIST,
|
|
1995
|
+
ORDERED_LIST,
|
|
1996
|
+
LINK,
|
|
1997
|
+
];
|
|
1998
|
+
const DEFAULT_LABELS = {
|
|
1999
|
+
toolbar: 'Text formatting',
|
|
2000
|
+
bold: 'Bold',
|
|
2001
|
+
italic: 'Italic',
|
|
2002
|
+
strikethrough: 'Strikethrough',
|
|
2003
|
+
code: 'Code',
|
|
2004
|
+
bulletList: 'Bulleted list',
|
|
2005
|
+
numberedList: 'Numbered list',
|
|
2006
|
+
link: 'Link',
|
|
2007
|
+
linkDialogTitle: 'Insert link',
|
|
2008
|
+
linkDialogDescription: 'Add a link to the selected text.',
|
|
2009
|
+
linkUrl: 'URL',
|
|
2010
|
+
linkUrlPlaceholder: 'https://example.com',
|
|
2011
|
+
linkText: 'Text',
|
|
2012
|
+
linkTextPlaceholder: 'Link text',
|
|
2013
|
+
linkInvalid: 'Enter a valid http(s) URL',
|
|
2014
|
+
linkRemove: 'Remove link',
|
|
2015
|
+
linkInsert: 'Insert',
|
|
2016
|
+
cancel: 'Cancel',
|
|
2017
|
+
slashTitle: 'Commands',
|
|
2018
|
+
slashEmpty: 'No commands yet',
|
|
2019
|
+
slashCyclesTitle: 'Cycles',
|
|
2020
|
+
slashCommandsTitle: 'Commands',
|
|
2021
|
+
emojiEmpty: 'No emoji found',
|
|
2022
|
+
streamEmpty: 'No streams found',
|
|
2023
|
+
streamPrivate: 'Private',
|
|
2024
|
+
streamPublic: 'Public',
|
|
2025
|
+
streamFallback: 'stream',
|
|
2026
|
+
};
|
|
2027
|
+
const NO_FORMATS = {
|
|
2028
|
+
bold: false,
|
|
2029
|
+
italic: false,
|
|
2030
|
+
strikethrough: false,
|
|
2031
|
+
code: false,
|
|
2032
|
+
bulletList: false,
|
|
2033
|
+
numberedList: false,
|
|
2034
|
+
link: false,
|
|
2035
|
+
headingTag: null,
|
|
2036
|
+
};
|
|
2037
|
+
const $activeSelection = () => {
|
|
2038
|
+
const selection = $getSelection();
|
|
2039
|
+
return $isRangeSelection(selection) ? selection : null;
|
|
2040
|
+
};
|
|
2041
|
+
const $linkAtSelection = (selection) => {
|
|
2042
|
+
for (const node of [
|
|
2043
|
+
selection.anchor.getNode(),
|
|
2044
|
+
selection.focus.getNode(),
|
|
2045
|
+
...selection.getNodes(),
|
|
2046
|
+
]) {
|
|
2047
|
+
if ($isLinkNode(node))
|
|
2048
|
+
return node;
|
|
2049
|
+
const parent = node.getParent();
|
|
2050
|
+
if ($isLinkNode(parent))
|
|
2051
|
+
return parent;
|
|
2052
|
+
}
|
|
2053
|
+
return null;
|
|
2054
|
+
};
|
|
2055
|
+
const headingTagOf = (tag) => tag === 'h2' || tag === 'h3' ? tag : null;
|
|
2056
|
+
const $selectionFormats = () => {
|
|
2057
|
+
const selection = $activeSelection();
|
|
2058
|
+
if (!selection)
|
|
2059
|
+
return NO_FORMATS;
|
|
2060
|
+
const topLevel = selection.anchor.getNode().getTopLevelElement();
|
|
2061
|
+
const listType = $isListNode(topLevel) ? topLevel.getListType() : null;
|
|
2062
|
+
return {
|
|
2063
|
+
bold: selection.hasFormat('bold'),
|
|
2064
|
+
italic: selection.hasFormat('italic'),
|
|
2065
|
+
strikethrough: selection.hasFormat('strikethrough'),
|
|
2066
|
+
code: selection.hasFormat('code'),
|
|
2067
|
+
bulletList: listType === 'bullet',
|
|
2068
|
+
numberedList: listType === 'number',
|
|
2069
|
+
link: $linkAtSelection(selection) !== null,
|
|
2070
|
+
headingTag: $isHeadingNode(topLevel) ? headingTagOf(topLevel.getTag()) : null,
|
|
2071
|
+
};
|
|
2072
|
+
};
|
|
2073
|
+
const formatsEqual = (a, b) => Object.keys(a).every((key) => a[key] === b[key]);
|
|
2074
|
+
function useSelectionFormats(editor) {
|
|
2075
|
+
const [formats, setFormats] = React.useState(NO_FORMATS);
|
|
2076
|
+
React.useEffect(() => {
|
|
2077
|
+
const refresh = () => editor.getEditorState().read(() => {
|
|
2078
|
+
const next = $selectionFormats();
|
|
2079
|
+
setFormats((prev) => (formatsEqual(prev, next) ? prev : next));
|
|
2080
|
+
});
|
|
2081
|
+
const unregisterUpdate = editor.registerUpdateListener(refresh);
|
|
2082
|
+
const unregisterSelection = editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
|
|
2083
|
+
refresh();
|
|
2084
|
+
return false;
|
|
2085
|
+
}, COMMAND_PRIORITY_LOW);
|
|
2086
|
+
return () => {
|
|
2087
|
+
unregisterUpdate();
|
|
2088
|
+
unregisterSelection();
|
|
2089
|
+
};
|
|
2090
|
+
}, [editor]);
|
|
2091
|
+
return formats;
|
|
2092
|
+
}
|
|
2093
|
+
const toggleList = (editor, listType, active) => {
|
|
2094
|
+
if (active) {
|
|
2095
|
+
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
editor.dispatchCommand(listType === 'bullet' ? INSERT_UNORDERED_LIST_COMMAND : INSERT_ORDERED_LIST_COMMAND, undefined);
|
|
2099
|
+
};
|
|
2100
|
+
/** Snapshot of the selection to edit a link from, taken before focus moves to the dialog. */
|
|
2101
|
+
const readLinkDraft = (editor) => editor.getEditorState().read(() => {
|
|
2102
|
+
const selection = $activeSelection();
|
|
2103
|
+
if (!selection)
|
|
2104
|
+
return { selection: null, url: '', text: '' };
|
|
2105
|
+
const link = $linkAtSelection(selection);
|
|
2106
|
+
return {
|
|
2107
|
+
selection: selection.clone(),
|
|
2108
|
+
url: link?.getURL() ?? '',
|
|
2109
|
+
text: link ? link.getTextContent() : selection.getTextContent(),
|
|
2110
|
+
};
|
|
2111
|
+
});
|
|
2112
|
+
const applyLink = (editor, draft, url, text) => {
|
|
2113
|
+
editor.update(() => {
|
|
2114
|
+
$setSelection(draft.selection ? draft.selection.clone() : null);
|
|
2115
|
+
const selection = $activeSelection() ?? $getRoot().selectEnd();
|
|
2116
|
+
const existing = $linkAtSelection(selection);
|
|
2117
|
+
if (existing) {
|
|
2118
|
+
existing.setURL(url);
|
|
2119
|
+
if (text !== existing.getTextContent()) {
|
|
2120
|
+
existing.clear();
|
|
2121
|
+
existing.append($createTextNode(text));
|
|
2122
|
+
}
|
|
2123
|
+
existing.selectEnd();
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
selection.removeText();
|
|
2127
|
+
const link = $createLinkNode(url, { rel: 'noopener noreferrer', target: '_blank' });
|
|
2128
|
+
link.append($createTextNode(text));
|
|
2129
|
+
$insertNodes([link]);
|
|
2130
|
+
link.selectEnd();
|
|
2131
|
+
});
|
|
2132
|
+
};
|
|
2133
|
+
const removeLink = (editor, draft) => {
|
|
2134
|
+
editor.update(() => {
|
|
2135
|
+
$setSelection(draft.selection ? draft.selection.clone() : null);
|
|
2136
|
+
const selection = $activeSelection();
|
|
2137
|
+
const link = selection ? $linkAtSelection(selection) : null;
|
|
2138
|
+
if (!link)
|
|
2139
|
+
return;
|
|
2140
|
+
for (const child of link.getChildren())
|
|
2141
|
+
link.insertBefore(child);
|
|
2142
|
+
link.remove();
|
|
2143
|
+
});
|
|
2144
|
+
};
|
|
2145
|
+
const normalizeUrl = (raw) => {
|
|
2146
|
+
const candidate = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
2147
|
+
try {
|
|
2148
|
+
const { protocol } = new URL(candidate);
|
|
2149
|
+
return /^https?:$/i.test(protocol) ? candidate : null;
|
|
2150
|
+
}
|
|
2151
|
+
catch {
|
|
2152
|
+
return null;
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
/**
|
|
2156
|
+
* Whole-clipboard paste that is a bare URL (Slack-style linkify-on-paste over a selection).
|
|
2157
|
+
* Rejects multi-word / non-URL clipboard text so ordinary paste is unchanged.
|
|
2158
|
+
*/
|
|
2159
|
+
const pastedUrlOf = (raw) => {
|
|
2160
|
+
const trimmed = raw.trim();
|
|
2161
|
+
if (!trimmed || /\s/.test(trimmed))
|
|
2162
|
+
return null;
|
|
2163
|
+
const match = URL_MATCHER.exec(trimmed);
|
|
2164
|
+
if (!match || match.index !== 0 || match[0].length !== trimmed.length)
|
|
2165
|
+
return null;
|
|
2166
|
+
return trimmed.startsWith('http') ? trimmed : `https://${trimmed}`;
|
|
2167
|
+
};
|
|
2168
|
+
/**
|
|
2169
|
+
* Pasting a bare URL over a non-empty selection wraps that text as a link (Slack parity).
|
|
2170
|
+
* Cmd/Ctrl+K is reserved for global search in the app, so it never opens the link dialog —
|
|
2171
|
+
* use the toolbar Link button, or paste a URL over a selection.
|
|
2172
|
+
*/
|
|
2173
|
+
function PasteLinkOnSelectionPlugin() {
|
|
2174
|
+
const [editor] = useLexicalComposerContext();
|
|
2175
|
+
React.useEffect(() => editor.registerCommand(PASTE_COMMAND, (event) => {
|
|
2176
|
+
// PasteCommandType is ClipboardEvent | InputEvent | KeyboardEvent; only clipboard pastes
|
|
2177
|
+
// carry text/plain. Avoid `instanceof` so jsdom test doubles still match.
|
|
2178
|
+
if (!('clipboardData' in event) || !event.clipboardData)
|
|
2179
|
+
return false;
|
|
2180
|
+
const raw = event.clipboardData.getData('text/plain');
|
|
2181
|
+
if (raw == null)
|
|
2182
|
+
return false;
|
|
2183
|
+
const url = pastedUrlOf(raw);
|
|
2184
|
+
if (!url)
|
|
2185
|
+
return false;
|
|
2186
|
+
const selection = $getSelection();
|
|
2187
|
+
if (!$isRangeSelection(selection) || selection.isCollapsed())
|
|
2188
|
+
return false;
|
|
2189
|
+
// Code blocks are source — let normal paste replace the selection, not wrap a link.
|
|
2190
|
+
if ($selectionIntersectsCodeBlock())
|
|
2191
|
+
return false;
|
|
2192
|
+
const text = selection.getTextContent();
|
|
2193
|
+
if (!text)
|
|
2194
|
+
return false;
|
|
2195
|
+
event.preventDefault();
|
|
2196
|
+
selection.removeText();
|
|
2197
|
+
const link = $createLinkNode(url, { rel: 'noopener noreferrer', target: '_blank' });
|
|
2198
|
+
link.append($createTextNode(text));
|
|
2199
|
+
$insertNodes([link]);
|
|
2200
|
+
link.selectEnd();
|
|
2201
|
+
return true;
|
|
2202
|
+
}, COMMAND_PRIORITY_HIGH), [editor]);
|
|
2203
|
+
return null;
|
|
2204
|
+
}
|
|
2205
|
+
function FormatButton({ label, active, onClick, children, }) {
|
|
2206
|
+
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Button, { type: "button", size: "icon-xs", variant: "ghost", "aria-label": label, "aria-pressed": active, className: cn(
|
|
2207
|
+
// Phone: 36px buttons with 20px icons (the 24px / 12px desktop set is too small to hit).
|
|
2208
|
+
"in-data-[platform=mobile]:size-9 in-data-[platform=mobile]:[&_svg:not([class*='size-'])]:size-5", active && 'bg-accent text-accent-foreground'),
|
|
2209
|
+
// Keep the editor selection: the toolbar must not take focus on click.
|
|
2210
|
+
onMouseDown: (event) => event.preventDefault(), onClick: onClick }), children: children }), _jsx(TooltipContent, { children: label })] }));
|
|
2211
|
+
}
|
|
2212
|
+
function FormattingToolbar({ labels, onLinkClick, documentMode, }) {
|
|
2213
|
+
const [editor] = useLexicalComposerContext();
|
|
2214
|
+
const formats = useSelectionFormats(editor);
|
|
2215
|
+
const fileInputRef = React.useRef(null);
|
|
2216
|
+
const toggleFormat = (format) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
|
2217
|
+
return (
|
|
2218
|
+
// Base UI tooltips need a provider ancestor; the editor brings its own so hosts need none.
|
|
2219
|
+
_jsx(TooltipProvider, { children: _jsxs("div", { role: "toolbar", "aria-label": labels.toolbar, "data-slot": "rich-text-editor-toolbar", className: cn('flex items-center gap-0.5 border-b px-1.5 py-1 in-data-[platform=mobile]:px-1 in-data-[platform=mobile]:py-1.5',
|
|
2220
|
+
// The document toolbar has nine controls: wrap on a phone rather than hide the image button.
|
|
2221
|
+
documentMode ? 'flex-wrap' : 'overflow-x-auto'), children: [documentMode ? (_jsxs(_Fragment, { children: [_jsx(FormatButton, { label: documentMode.labels.heading2, active: formats.headingTag === 'h2', onClick: () => toggleHeading(editor, 'h2'), children: _jsx("span", { "aria-hidden": true, className: "text-xs font-semibold", children: "H2" }) }), _jsx(FormatButton, { label: documentMode.labels.heading3, active: formats.headingTag === 'h3', onClick: () => toggleHeading(editor, 'h3'), children: _jsx("span", { "aria-hidden": true, className: "text-xs font-semibold", children: "H3" }) }), _jsx(Separator, { orientation: "vertical", className: "mx-1 h-4" })] })) : null, _jsx(FormatButton, { label: labels.bold, active: formats.bold, onClick: () => toggleFormat('bold'), children: _jsx(Bold, { "aria-hidden": true }) }), _jsx(FormatButton, { label: labels.italic, active: formats.italic, onClick: () => toggleFormat('italic'), children: _jsx(Italic, { "aria-hidden": true }) }), documentMode ? null : (_jsx(FormatButton, { label: labels.strikethrough, active: formats.strikethrough, onClick: () => toggleFormat('strikethrough'), children: _jsx(Strikethrough, { "aria-hidden": true }) })), _jsx(FormatButton, { label: labels.code, active: formats.code, onClick: () => toggleFormat('code'), children: _jsx(Code, { "aria-hidden": true }) }), _jsx(Separator, { orientation: "vertical", className: "mx-1 h-4" }), _jsx(FormatButton, { label: labels.bulletList, active: formats.bulletList, onClick: () => toggleList(editor, 'bullet', formats.bulletList), children: _jsx(List, { "aria-hidden": true }) }), _jsx(FormatButton, { label: labels.numberedList, active: formats.numberedList, onClick: () => toggleList(editor, 'number', formats.numberedList), children: _jsx(ListOrdered, { "aria-hidden": true }) }), _jsx(Separator, { orientation: "vertical", className: "mx-1 h-4" }), _jsx(FormatButton, { label: labels.link, active: formats.link, onClick: onLinkClick, children: _jsx(LinkIcon, { "aria-hidden": true }) }), documentMode ? (_jsxs(_Fragment, { children: [_jsx(FormatButton, { label: documentMode.labels.image, active: false, onClick: () => fileInputRef.current?.click(), children: _jsx(ImageIcon, { "aria-hidden": true }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, hidden: true, accept: documentMode.options.acceptedImageTypes.join(','), onChange: (event) => {
|
|
2222
|
+
const files = Array.from(event.target.files ?? []);
|
|
2223
|
+
event.target.value = '';
|
|
2224
|
+
void documentMode.addFiles(files);
|
|
2225
|
+
} })] })) : null] }) }));
|
|
2226
|
+
}
|
|
2227
|
+
/** Mounted only while a draft exists, so the fields initialise from it and reset on every open. */
|
|
2228
|
+
function LinkDialog({ draft, labels, onClose, }) {
|
|
2229
|
+
const [editor] = useLexicalComposerContext();
|
|
2230
|
+
const [url, setUrl] = React.useState(draft.url);
|
|
2231
|
+
const [text, setText] = React.useState(draft.text);
|
|
2232
|
+
const [invalid, setInvalid] = React.useState(false);
|
|
2233
|
+
const urlId = React.useId();
|
|
2234
|
+
const textId = React.useId();
|
|
2235
|
+
const errorId = React.useId();
|
|
2236
|
+
const finish = () => {
|
|
2237
|
+
onClose();
|
|
2238
|
+
// The dialog has focus; give it back to the editor once it has closed.
|
|
2239
|
+
requestAnimationFrame(() => editor.focus());
|
|
2240
|
+
};
|
|
2241
|
+
const submit = () => {
|
|
2242
|
+
const normalized = normalizeUrl(url.trim());
|
|
2243
|
+
if (!normalized) {
|
|
2244
|
+
setInvalid(true);
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
applyLink(editor, draft, normalized, text.trim() || url.trim());
|
|
2248
|
+
finish();
|
|
2249
|
+
};
|
|
2250
|
+
const remove = () => {
|
|
2251
|
+
removeLink(editor, draft);
|
|
2252
|
+
finish();
|
|
2253
|
+
};
|
|
2254
|
+
return (_jsx(Dialog, { open: true, onOpenChange: (open) => (open ? undefined : finish()), children: _jsx(DialogContent, { children: _jsxs("form", { className: "contents", onSubmit: (event) => {
|
|
2255
|
+
event.preventDefault();
|
|
2256
|
+
submit();
|
|
2257
|
+
}, children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: labels.linkDialogTitle }), _jsx(DialogDescription, { children: labels.linkDialogDescription })] }), _jsxs("div", { className: "grid gap-3", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: urlId, children: labels.linkUrl }), _jsx(Input, { id: urlId,
|
|
2258
|
+
// Plain text: native `type="url"` validation would block scheme-less input that
|
|
2259
|
+
// normalizeUrl accepts (e.g. `example.com`), and would swallow the submit event.
|
|
2260
|
+
type: "text", inputMode: "url", autoComplete: "off", autoFocus: true, placeholder: labels.linkUrlPlaceholder, value: url, "aria-invalid": invalid || undefined, "aria-describedby": invalid ? errorId : undefined, onChange: (event) => {
|
|
2261
|
+
setUrl(event.target.value);
|
|
2262
|
+
setInvalid(false);
|
|
2263
|
+
} }), invalid ? (_jsx("p", { id: errorId, role: "alert", className: "text-xs text-destructive", children: labels.linkInvalid })) : null] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: textId, children: labels.linkText }), _jsx(Input, { id: textId, placeholder: labels.linkTextPlaceholder, value: text, onChange: (event) => setText(event.target.value) })] })] }), _jsxs(DialogFooter, { children: [draft?.url ? (_jsx(Button, { type: "button", variant: "ghost", onClick: remove, children: labels.linkRemove })) : null, _jsx(Button, { type: "button", variant: "outline", onClick: finish, children: labels.cancel }), _jsx(Button, { type: "submit", children: labels.linkInsert })] })] }) }) }));
|
|
2264
|
+
}
|
|
2265
|
+
// ── Content direction (HAC #1045) ────────────────────────────────────────────
|
|
2266
|
+
const DIRECTION_PLUGIN_TAG = 'direction-plugin';
|
|
2267
|
+
/** Survives empty composer / send remount so typing Hebrew again keeps RTL. */
|
|
2268
|
+
let lastRememberedDirection = 'ltr';
|
|
2269
|
+
/** Test-only: reset module-scoped remembered direction between cases. */
|
|
2270
|
+
function __resetLastRememberedDirectionForTests() {
|
|
2271
|
+
lastRememberedDirection = 'ltr';
|
|
2272
|
+
}
|
|
2273
|
+
/**
|
|
2274
|
+
* Plain text used for word-vote direction: mentions, cycle triggers, stream
|
|
2275
|
+
* chips and link labels are excluded so `@Name` then Hebrew does not lock LTR.
|
|
2276
|
+
*/
|
|
2277
|
+
const plainTextForDirection = (node) => {
|
|
2278
|
+
let out = '';
|
|
2279
|
+
for (const child of node.getChildren()) {
|
|
2280
|
+
if ($isMentionNode(child) ||
|
|
2281
|
+
$isCycleTriggerNode(child) ||
|
|
2282
|
+
$isStreamChipNode(child) ||
|
|
2283
|
+
$isCustomEmojiNode(child) ||
|
|
2284
|
+
$isLinkNode(child))
|
|
2285
|
+
continue;
|
|
2286
|
+
if ($isTextNode(child)) {
|
|
2287
|
+
out += child.getTextContent();
|
|
2288
|
+
continue;
|
|
2289
|
+
}
|
|
2290
|
+
if ($isElementNode(child))
|
|
2291
|
+
out += plainTextForDirection(child);
|
|
2292
|
+
}
|
|
2293
|
+
return out;
|
|
2294
|
+
};
|
|
2295
|
+
/**
|
|
2296
|
+
* Recomputes paragraph / list / code `dir` with the same word-vote as
|
|
2297
|
+
* `detectTextDirection`. Lexical's native first-strong `dir="auto"` is not enough
|
|
2298
|
+
* once mention display names enter the signal.
|
|
2299
|
+
*
|
|
2300
|
+
* Stamps the live DOM (HAC) and Lexical `setDirection` so caret alignment and
|
|
2301
|
+
* serialized `direction` stay in sync.
|
|
2302
|
+
*/
|
|
2303
|
+
function DirectionPlugin({ enabled = true }) {
|
|
2304
|
+
const [editor] = useLexicalComposerContext();
|
|
2305
|
+
React.useEffect(() => {
|
|
2306
|
+
if (!enabled)
|
|
2307
|
+
return;
|
|
2308
|
+
const stampDirections = () => {
|
|
2309
|
+
const updates = [];
|
|
2310
|
+
editor.getEditorState().read(() => {
|
|
2311
|
+
for (const child of $getRoot().getChildren()) {
|
|
2312
|
+
const dom = editor.getElementByKey(child.getKey());
|
|
2313
|
+
let dir;
|
|
2314
|
+
if ($isCodeNode(child)) {
|
|
2315
|
+
dir = 'ltr';
|
|
2316
|
+
}
|
|
2317
|
+
else if ($isListNode(child) || $isParagraphNode(child)) {
|
|
2318
|
+
const signal = plainTextForDirection(child);
|
|
2319
|
+
if (!hasStrongCharacter(signal)) {
|
|
2320
|
+
// Empty / "@" / "/" / mention-only blocks keep the last typed direction (HAC remount
|
|
2321
|
+
// memory) on the DOM only: a model update here would re-run the typeahead listener
|
|
2322
|
+
// mid-trigger and close the menu the composer's @ and / buttons just opened.
|
|
2323
|
+
if (dom && dom.getAttribute('dir') !== lastRememberedDirection)
|
|
2324
|
+
dom.setAttribute('dir', lastRememberedDirection);
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
dir = detectTextDirection(signal);
|
|
2328
|
+
lastRememberedDirection = dir;
|
|
2329
|
+
}
|
|
2330
|
+
else {
|
|
2331
|
+
continue;
|
|
2332
|
+
}
|
|
2333
|
+
if (dom && dom.getAttribute('dir') !== dir)
|
|
2334
|
+
dom.setAttribute('dir', dir);
|
|
2335
|
+
if (child.getDirection() !== dir)
|
|
2336
|
+
updates.push({ key: child.getKey(), dir });
|
|
2337
|
+
}
|
|
2338
|
+
});
|
|
2339
|
+
if (updates.length === 0)
|
|
2340
|
+
return;
|
|
2341
|
+
editor.update(() => {
|
|
2342
|
+
for (const { key, dir } of updates) {
|
|
2343
|
+
const node = $getNodeByKey(key);
|
|
2344
|
+
if ($isElementNode(node) && node.getDirection() !== dir)
|
|
2345
|
+
node.setDirection(dir);
|
|
2346
|
+
}
|
|
2347
|
+
}, { tag: DIRECTION_PLUGIN_TAG });
|
|
2348
|
+
};
|
|
2349
|
+
stampDirections();
|
|
2350
|
+
return editor.registerUpdateListener(({ tags }) => {
|
|
2351
|
+
if (tags.has(DIRECTION_PLUGIN_TAG))
|
|
2352
|
+
return;
|
|
2353
|
+
stampDirections();
|
|
2354
|
+
});
|
|
2355
|
+
}, [editor, enabled]);
|
|
2356
|
+
return null;
|
|
2357
|
+
}
|
|
2358
|
+
const editorNodes = [
|
|
2359
|
+
HeadingNode,
|
|
2360
|
+
QuoteNode,
|
|
2361
|
+
ListNode,
|
|
2362
|
+
ListItemNode,
|
|
2363
|
+
LinkNode,
|
|
2364
|
+
AutoLinkNode,
|
|
2365
|
+
CodeNode,
|
|
2366
|
+
MentionNode,
|
|
2367
|
+
CycleTriggerNode,
|
|
2368
|
+
StreamChipNode,
|
|
2369
|
+
CustomEmojiNode,
|
|
2370
|
+
DocumentImageNode,
|
|
2371
|
+
];
|
|
2372
|
+
// Module-level so its identity is stable: an inline one is a new component type on every render,
|
|
2373
|
+
// which remounts every decorator (and drops focus from inputs inside them, e.g. an image's alt field).
|
|
2374
|
+
function PassthroughErrorBoundary({ children }) {
|
|
2375
|
+
return _jsx(_Fragment, { children: children });
|
|
2376
|
+
}
|
|
2377
|
+
function RichTextEditorInner({ value: _value, onChange, placeholder, searchMentions, onMentionSelect, searchSlashCommands, onSlashCommandSelect, onSlashTyped, searchEmojis, resolveEmojiShortcode, onEmojiUsed, searchStreams, onSubmit, sendOnEnter = true, insertCycleTrigger, insertCustomEmoji, chipSlashCommands, onCycleTriggerInserted, onCustomEmojiInserted, insertTextAtCaret, onInsertTextAtCaret, disabled, autoFocus = false, toolbar = true, detectContentDirection = true, labels: labelOverrides, documentMode, 'aria-label': ariaLabel, className, }) {
|
|
2378
|
+
const [editor] = useLexicalComposerContext();
|
|
2379
|
+
const documentLabelOverrides = documentMode?.labels;
|
|
2380
|
+
const documentLabels = React.useMemo(() => ({ ...DEFAULT_DOCUMENT_LABELS, ...documentLabelOverrides }), [documentLabelOverrides]);
|
|
2381
|
+
const documentImages = useDocumentImages(editor, documentMode);
|
|
2382
|
+
const documentContext = React.useMemo(() => (documentMode ? { options: documentMode, labels: documentLabels } : null), [documentMode, documentLabels]);
|
|
2383
|
+
const [linkDraft, setLinkDraft] = React.useState(null);
|
|
2384
|
+
const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides]);
|
|
2385
|
+
const showToolbar = toolbar && !disabled;
|
|
2386
|
+
const openLinkDialog = React.useCallback(() => setLinkDraft(readLinkDraft(editor)), [editor]);
|
|
2387
|
+
return (_jsx(DocumentModeCtx.Provider, { value: documentContext, children: _jsx(StreamChipLabelsCtx.Provider, { value: {
|
|
2388
|
+
streamPrivate: labels.streamPrivate,
|
|
2389
|
+
streamPublic: labels.streamPublic,
|
|
2390
|
+
streamFallback: labels.streamFallback,
|
|
2391
|
+
}, children: _jsxs("div", { "data-slot": "rich-text-editor", className: cn('flex min-h-0 flex-col rounded-md border', className), children: [_jsx(Collapse, { open: showToolbar, children: _jsx(FormattingToolbar, { labels: labels, onLinkClick: openLinkDialog, documentMode: documentContext
|
|
2392
|
+
? { ...documentContext, addFiles: documentImages.addFiles }
|
|
2393
|
+
: undefined }) }), _jsx("div", { className: "relative min-h-0 flex-1 overflow-y-auto", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { "aria-label": ariaLabel, "aria-multiline": true, role: "textbox", className: "min-h-9 w-full resize-none px-3 py-1.5 text-sm outline-none in-data-[platform=mobile]:text-base disabled:cursor-not-allowed disabled:opacity-50", disabled: disabled }), placeholder: placeholder ? (_jsx("div", { className: "pointer-events-none absolute top-1.5 start-3 text-sm text-muted-foreground", children: placeholder })) : null, ErrorBoundary: PassthroughErrorBoundary }) }), autoFocus ? _jsx(AutoFocusPlugin, {}) : null, _jsx(HistoryPlugin, {}), _jsx(ListPlugin, {}), _jsx(ListTabIndentPlugin, {}), _jsx(LinkPlugin, { validateUrl: (url) => /^https?:\/\//i.test(url) }), _jsx(AutoLinkPlugin, { matchers: MATCHERS, excludeParents: AUTO_LINK_EXCLUDE_PARENTS }), _jsx(MarkdownShortcutPlugin, { transformers: documentMode ? DOCUMENT_MARKDOWN_TRANSFORMERS : MARKDOWN_TRANSFORMERS }), _jsx(PasteLinkOnSelectionPlugin, {}), linkDraft ? (_jsx(LinkDialog, { draft: linkDraft, labels: labels, onClose: () => setLinkDraft(null) })) : null, _jsx(OnChangePlugin, { onChange: (editorState) => {
|
|
2394
|
+
onChange(editorState.toJSON());
|
|
2395
|
+
} }), _jsx(EnterSubmitPlugin, { onSubmit: onSubmit, sendOnEnter: sendOnEnter }), documentMode ? null : _jsx(CodeFenceOnThirdBacktickPlugin, {}), _jsx(InsertTextAtCaretPlugin, { request: insertTextAtCaret, onInserted: onInsertTextAtCaret }), _jsx(TypeaheadEditGatePlugin, {}), _jsx(MentionsPluginWithPortal, { searchMentions: searchMentions, onMentionSelect: onMentionSelect }), _jsx(SlashCommandsPlugin, { searchSlashCommands: searchSlashCommands, onSelectCommand: onSlashCommandSelect, onSlashTyped: onSlashTyped, labels: labels }), _jsx(EmojiTypeaheadPlugin, { searchEmojis: searchEmojis, labels: labels, onEmojiUsed: onEmojiUsed }), _jsx(EmojiShortcodeReplacePlugin, { resolveEmojiShortcode: resolveEmojiShortcode, onEmojiUsed: onEmojiUsed }), _jsx(StreamChipsTypeaheadPlugin, { searchStreams: searchStreams, labels: labels }), _jsx(CycleTriggerPlugin, { request: insertCycleTrigger, onInserted: onCycleTriggerInserted }), _jsx(CustomEmojiPlugin, { request: insertCustomEmoji, onInserted: onCustomEmojiInserted }), _jsx(SlashCommandChipPlugin, { commands: chipSlashCommands }), _jsx(DirectionPlugin, { enabled: detectContentDirection })] }) }) }));
|
|
2396
|
+
}
|
|
2397
|
+
/**
|
|
2398
|
+
* Controlled rich-text editor built on Lexical.
|
|
2399
|
+
*
|
|
2400
|
+
* Registered nodes: `HeadingNode`, `QuoteNode`, `ListNode`, `ListItemNode`,
|
|
2401
|
+
* `LinkNode`, `AutoLinkNode`, `CodeNode`, `MentionNode` (`type: 'mention'`),
|
|
2402
|
+
* `CycleTriggerNode` (`type: 'cycle-trigger'`), `StreamChipNode` (`type: 'stream-chip'`),
|
|
2403
|
+
* `CustomEmojiNode` (`type: 'custom-emoji'`), `DocumentImageNode` (`type: 'about-image'`) — compatible with the
|
|
2404
|
+
* `contracts/chat/adapters/lexical.ts` round-trip contract.
|
|
2405
|
+
*/
|
|
2406
|
+
function RichTextEditor(props) {
|
|
2407
|
+
const initialConfig = React.useMemo(() => ({
|
|
2408
|
+
namespace: 'RichTextEditor',
|
|
2409
|
+
theme: editorTheme,
|
|
2410
|
+
nodes: editorNodes,
|
|
2411
|
+
onError: (error) => console.error(error),
|
|
2412
|
+
editorState: props.value ? JSON.stringify(props.value) : null,
|
|
2413
|
+
editable: !props.disabled,
|
|
2414
|
+
}),
|
|
2415
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2416
|
+
[]);
|
|
2417
|
+
return (_jsx(CycleTriggerIssueCtx.Provider, { value: props.cycleTriggerIssue ?? null, children: _jsx(LexicalComposer, { initialConfig: initialConfig, children: _jsx(RichTextEditorInner, { ...props }) }) }));
|
|
2418
|
+
}
|
|
2419
|
+
export { RichTextEditor, __resetLastRememberedDirectionForTests };
|