@lovett/ui 0.0.11 → 0.2.0
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/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,1601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RichThreadComposerImpl — the TipTap surface behind the rich composer.
|
|
3
|
+
*
|
|
4
|
+
* ADR-148. This module is reached ONLY through the `lazy()` boundary in
|
|
5
|
+
* rich-composer.tsx (D5): every TipTap import here is static and ordinary, so
|
|
6
|
+
* Vite splits the whole editor into one chunk and the D6 fallback wraps one
|
|
7
|
+
* promise. Nothing in this file is re-exported from the package barrel.
|
|
8
|
+
*
|
|
9
|
+
* Two components. The default export is the GATE (D3 rule 2): it runs
|
|
10
|
+
* `isRichEditable` on the draft before any editor exists and, if the body
|
|
11
|
+
* holds syntax the subset cannot carry, renders the plain composer with the
|
|
12
|
+
* source untouched and a one-line notice. `RichSurface` below it owns the
|
|
13
|
+
* editor. The gate lives here rather than in the shell because the detector
|
|
14
|
+
* needs the subset's schema, and the shell must stay free of TipTap.
|
|
15
|
+
*
|
|
16
|
+
* The seam stays `ThreadComposerProps`: markdown in through `value`, markdown
|
|
17
|
+
* out through `onChange`, and verified attachment ticket ids out through the
|
|
18
|
+
* optional `onSubmit` argument (ADR-151 D1). Hooks and host capabilities sit
|
|
19
|
+
* on top: `onEditor`, `mentionItems`, upload/GIF callbacks, limits, and the
|
|
20
|
+
* shell's polite `announce` region. Missing capabilities render no inert UI.
|
|
21
|
+
*
|
|
22
|
+
* DRAFT TIMING (D12). `onChange` receives serialized markdown on a 300 ms
|
|
23
|
+
* trailing debounce, not on every transaction, and is flushed on blur, send
|
|
24
|
+
* and unmount. Because the parent is therefore up to 300 ms behind the editor,
|
|
25
|
+
* SEND does not call `onSubmit` until the parent has caught up: `requestSubmit`
|
|
26
|
+
* flushes, and an effect fires `onSubmit` once `value` equals what was flushed
|
|
27
|
+
* and every attachment has a verified ticket — so `useThread.submit`, which
|
|
28
|
+
* reads the draft it was rendered with, always sends the latest keystrokes.
|
|
29
|
+
* `canSend` reads the editor's own emptiness for the same reason.
|
|
30
|
+
*
|
|
31
|
+
* KEYBOARD (D9). Every Escape level lives in ONE `handleKeyDown`: bubble menu,
|
|
32
|
+
* then Suggestion popup, then `onCancel`. `editorProps` handlers run before
|
|
33
|
+
* any extension keymap, which is what makes the order a fact rather than a
|
|
34
|
+
* hope. Tab is answered from `handleDOMEvents.keydown` returning true — that
|
|
35
|
+
* skips ProseMirror's keymaps (StarterKit binds Tab to indent a list item)
|
|
36
|
+
* WITHOUT preventing the browser default, so focus leaves the editor. While a
|
|
37
|
+
* suggestion popup is open Tab is NOT waved through: ADR-149 D5 gives it to
|
|
38
|
+
* the popup, so the DOM-event handler stands down and `handleKeyDown` inserts.
|
|
39
|
+
*
|
|
40
|
+
* SUGGESTIONS (ADR-149). Two triggers, ONE popup. `markdown-spec.ts` owns the
|
|
41
|
+
* data half (what `@` and `:` offer, and what choosing inserts); this file
|
|
42
|
+
* owns the UI half: the `render()` lifecycle each trigger gets, the single
|
|
43
|
+
* `<SuggestionList>` both of them draw into, and the keys. The popup NEVER
|
|
44
|
+
* takes focus — it is mounted in a portal outside the editor and pointed at by
|
|
45
|
+
* `aria-controls` / `aria-activedescendant` on the contenteditable itself, so
|
|
46
|
+
* `document.activeElement` is the editor through open, arrow, insert and
|
|
47
|
+
* escape (D5, AC#6).
|
|
48
|
+
*
|
|
49
|
+
* IME (D11). The bubble predicate refuses while `view.composing` or our own
|
|
50
|
+
* composition flag is set; `compositionstart` closes an open menu outright.
|
|
51
|
+
*
|
|
52
|
+
* What jsdom cannot verify here, and the tests say so: the bubble menu's
|
|
53
|
+
* pixel placement (floating-ui needs layout) and real composition sessions.
|
|
54
|
+
* Both are on the owner's eyeball + IME pass (AC#13).
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from 'react'
|
|
58
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
|
59
|
+
import { createPortal } from 'react-dom'
|
|
60
|
+
import {
|
|
61
|
+
Bold,
|
|
62
|
+
Check,
|
|
63
|
+
Code,
|
|
64
|
+
CornerDownRight,
|
|
65
|
+
Heading1,
|
|
66
|
+
Heading2,
|
|
67
|
+
Heading3,
|
|
68
|
+
Italic,
|
|
69
|
+
Images,
|
|
70
|
+
Link as LinkIcon,
|
|
71
|
+
List,
|
|
72
|
+
ListOrdered,
|
|
73
|
+
Minus,
|
|
74
|
+
SquareCode,
|
|
75
|
+
Strikethrough,
|
|
76
|
+
TextQuote,
|
|
77
|
+
Underline as UnderlineIcon,
|
|
78
|
+
Unlink,
|
|
79
|
+
X,
|
|
80
|
+
} from 'lucide-react'
|
|
81
|
+
import { isTextSelection, type Editor } from '@tiptap/core'
|
|
82
|
+
import { Placeholder } from '@tiptap/extension-placeholder'
|
|
83
|
+
import { Fragment, Node as ProseMirrorNode, Slice } from '@tiptap/pm/model'
|
|
84
|
+
import type { PluginKey } from '@tiptap/pm/state'
|
|
85
|
+
import { PluginKey as ProseMirrorPluginKey } from '@tiptap/pm/state'
|
|
86
|
+
import type { EditorView } from '@tiptap/pm/view'
|
|
87
|
+
import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
|
|
88
|
+
import { BubbleMenu } from '@tiptap/react/menus'
|
|
89
|
+
import { Button } from '../button'
|
|
90
|
+
import { Kbd } from '../kbd'
|
|
91
|
+
import { cn } from '../lib/utils'
|
|
92
|
+
import { Popover } from '../popover'
|
|
93
|
+
import {
|
|
94
|
+
ATTACHMENT_ACCEPT,
|
|
95
|
+
AttachmentButton,
|
|
96
|
+
AttachmentDropZone,
|
|
97
|
+
AttachmentTray,
|
|
98
|
+
GifPicker,
|
|
99
|
+
} from './attachments'
|
|
100
|
+
import type { MentionTargetType } from './comment-body'
|
|
101
|
+
import type { MentionSuggestionItem } from './types'
|
|
102
|
+
import { ThreadComposer, type ThreadComposerProps } from './composer'
|
|
103
|
+
import { ComposerFooter } from './composer-footer'
|
|
104
|
+
import { caretScrollDelta } from './scroll-caret'
|
|
105
|
+
import { searchEmoji } from './emoji'
|
|
106
|
+
import {
|
|
107
|
+
EMOJI_SUGGESTION_MIN_QUERY,
|
|
108
|
+
MENTION_SUGGESTION_MIN_QUERY,
|
|
109
|
+
THREAD_EMOJI_SUGGESTION_KEY,
|
|
110
|
+
THREAD_MENTION_SUGGESTION_KEY,
|
|
111
|
+
isRichEditable,
|
|
112
|
+
parseRichMarkdown,
|
|
113
|
+
richSubsetExtensions,
|
|
114
|
+
serializeRichMarkdown,
|
|
115
|
+
type EmojiSuggestionAttrs,
|
|
116
|
+
} from './markdown-spec'
|
|
117
|
+
import { ComposerNotice, type RichComposerAttachmentProps } from './rich-composer'
|
|
118
|
+
import { SuggestionList, wrapIndex, type SuggestionOption } from './suggestion-list'
|
|
119
|
+
import { useAttachments } from './use-attachments'
|
|
120
|
+
|
|
121
|
+
/** D12: the draft leaves the editor on this trailing debounce. */
|
|
122
|
+
export const DRAFT_DEBOUNCE_MS = 300
|
|
123
|
+
|
|
124
|
+
/** D3 rule 2, in the user's words. Rendered above the plain composer and spoken once. */
|
|
125
|
+
export const FAIL_CLOSED_NOTICE =
|
|
126
|
+
'Opened in the plain editor: this comment uses formatting the rich editor cannot hold. Nothing was changed.'
|
|
127
|
+
|
|
128
|
+
export type { MentionSuggestionItem } from './types'
|
|
129
|
+
|
|
130
|
+
/** Hooks on top of the seam. None of these is part of `ThreadComposerProps`. */
|
|
131
|
+
export interface RichComposerHooks extends RichComposerAttachmentProps {
|
|
132
|
+
/** Receives the live editor once created, and null when it goes away. */
|
|
133
|
+
readonly onEditor?: ((editor: Editor | null) => void) | undefined
|
|
134
|
+
/** ADR-149's seam: items for the `@` picker. Absent, the popup never opens. */
|
|
135
|
+
readonly mentionItems?: ((query: string) => readonly MentionSuggestionItem[]) | undefined
|
|
136
|
+
/** The shell's polite live region. */
|
|
137
|
+
readonly announce?: ((message: string) => void) | undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type RichThreadComposerImplProps = ThreadComposerProps & RichComposerHooks
|
|
141
|
+
|
|
142
|
+
/* ── Platform spelling, read once (same reasoning as composer.tsx) ─────────── */
|
|
143
|
+
|
|
144
|
+
const IS_APPLE: boolean =
|
|
145
|
+
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
|
146
|
+
const SEND_KEYCAP = IS_APPLE ? '⌘↵' : 'Ctrl ↵'
|
|
147
|
+
const MOD_NAME = IS_APPLE ? 'Command' : 'Control'
|
|
148
|
+
|
|
149
|
+
/** React accepts CSS custom properties in `style`; the DOM typings do not declare them. */
|
|
150
|
+
type StyleWithVars = CSSProperties & Record<`--${string}`, string>
|
|
151
|
+
|
|
152
|
+
const BUBBLE_MENU_KEY = new ProseMirrorPluginKey('threadBubbleMenu')
|
|
153
|
+
|
|
154
|
+
/* ── The gate (D3 rule 2) ─────────────────────────────────────────────────── */
|
|
155
|
+
|
|
156
|
+
export default function RichThreadComposerImpl(props: RichThreadComposerImplProps) {
|
|
157
|
+
const { value, onChange, announce } = props
|
|
158
|
+
|
|
159
|
+
// What the parent currently holds BECAUSE we emitted it. A `value` that
|
|
160
|
+
// differs from it arrived from outside — a resumed draft, a comment opened
|
|
161
|
+
// for editing, a reset after send — and is the only kind that can change the
|
|
162
|
+
// gate's answer. Our own emissions never re-run the detector: the editor
|
|
163
|
+
// cannot produce syntax the editor cannot hold.
|
|
164
|
+
const lastEmitted = useRef<string | null>(null)
|
|
165
|
+
const [gate, setGate] = useState(() => ({ value, rich: isRichEditable(value) }))
|
|
166
|
+
if (value !== gate.value) {
|
|
167
|
+
setGate({ value, rich: value === lastEmitted.current ? gate.rich : isRichEditable(value) })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const emit = useCallback(
|
|
171
|
+
(next: string) => {
|
|
172
|
+
lastEmitted.current = next
|
|
173
|
+
onChange(next)
|
|
174
|
+
},
|
|
175
|
+
[onChange],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!gate.rich) announce?.(FAIL_CLOSED_NOTICE)
|
|
180
|
+
}, [announce, gate.rich])
|
|
181
|
+
|
|
182
|
+
if (!gate.rich) {
|
|
183
|
+
const {
|
|
184
|
+
onEditor: _onEditor,
|
|
185
|
+
mentionItems: _items,
|
|
186
|
+
announce: _announce,
|
|
187
|
+
uploadAttachment: _uploadAttachment,
|
|
188
|
+
attachGif: _attachGif,
|
|
189
|
+
searchGifs: _searchGifs,
|
|
190
|
+
attachmentAccept: _attachmentAccept,
|
|
191
|
+
attachmentMaxPerComment: _attachmentMaxPerComment,
|
|
192
|
+
attachmentMaxFileBytes: _attachmentMaxFileBytes,
|
|
193
|
+
attachmentMaxTotalBytes: _attachmentMaxTotalBytes,
|
|
194
|
+
...plain
|
|
195
|
+
} = props
|
|
196
|
+
return (
|
|
197
|
+
<div className="flex flex-col gap-[var(--space-1)]">
|
|
198
|
+
<ComposerNotice>{FAIL_CLOSED_NOTICE}</ComposerNotice>
|
|
199
|
+
<ThreadComposer {...plain} onChange={emit} />
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
return <RichSurface {...props} onChange={emit} />
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ── The suggestion popup (ADR-149) ───────────────────────────────────────── */
|
|
207
|
+
|
|
208
|
+
/** Which trigger a popup session belongs to. Two configs, one popup (D1). */
|
|
209
|
+
type SuggestionKind = 'mention' | 'emoji'
|
|
210
|
+
|
|
211
|
+
/** What choosing an option hands back. Both shapes carry `id` + `label`. */
|
|
212
|
+
type SuggestionAttrs = MentionSuggestionItem | EmojiSuggestionAttrs
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The slice of `@tiptap/suggestion`'s render props this file actually uses,
|
|
216
|
+
* declared locally and precisely.
|
|
217
|
+
*
|
|
218
|
+
* `@tiptap/suggestion` types `SuggestionProps` with `I = any`, so reading
|
|
219
|
+
* `props.items` or `props.command` through its own types would leak `any` into
|
|
220
|
+
* this file (CLAUDE.md §3). A narrower parameter type is still assignable — a
|
|
221
|
+
* handler that accepts less than it is given always is — so this costs
|
|
222
|
+
* nothing and keeps the file free of `any`.
|
|
223
|
+
*/
|
|
224
|
+
interface SuggestionRenderProps {
|
|
225
|
+
readonly query: string
|
|
226
|
+
readonly command: (attrs: SuggestionAttrs) => void
|
|
227
|
+
readonly mount: (
|
|
228
|
+
element: HTMLElement,
|
|
229
|
+
options?: {
|
|
230
|
+
onPosition?: (data: { x: number; y: number; strategy: 'absolute' | 'fixed' }) => void
|
|
231
|
+
},
|
|
232
|
+
) => () => void
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The rows a session is showing: what to draw, and what to insert. */
|
|
236
|
+
interface SuggestionRows {
|
|
237
|
+
readonly options: readonly SuggestionOption[]
|
|
238
|
+
readonly attrs: readonly SuggestionAttrs[]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const EMPTY_ROWS: SuggestionRows = { options: [], attrs: [] }
|
|
242
|
+
|
|
243
|
+
const SUGGESTION_KEYS: Readonly<Record<SuggestionKind, PluginKey>> = {
|
|
244
|
+
mention: THREAD_MENTION_SUGGESTION_KEY,
|
|
245
|
+
emoji: THREAD_EMOJI_SUGGESTION_KEY,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const SUGGESTION_LABELS: Readonly<Record<SuggestionKind, string>> = {
|
|
249
|
+
mention: 'Mention someone',
|
|
250
|
+
emoji: 'Emoji',
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The same two, as the noun the polite announcement counts. */
|
|
254
|
+
const SUGGESTION_NOUNS: Readonly<Record<SuggestionKind, string>> = {
|
|
255
|
+
mention: 'mention',
|
|
256
|
+
emoji: 'emoji',
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* D4's thresholds, enforced HERE and not only on the plugin.
|
|
261
|
+
*
|
|
262
|
+
* `minQueryLength` in the Suggestion config stops the plugin doing needless
|
|
263
|
+
* `items()` work, but all it does to the renderer is hand it an empty item
|
|
264
|
+
* list — and this file computes its rows from the QUERY rather than from
|
|
265
|
+
* `props.items` (see `SuggestionRenderProps`). So the threshold has to be a
|
|
266
|
+
* rule the popup itself applies, or a host whose `mentionItems` ignores its
|
|
267
|
+
* query would open the popup on a bare `@`. Same constants, one behaviour.
|
|
268
|
+
*/
|
|
269
|
+
const SUGGESTION_MIN_QUERY: Readonly<Record<SuggestionKind, number>> = {
|
|
270
|
+
mention: MENTION_SUGGESTION_MIN_QUERY,
|
|
271
|
+
emoji: EMOJI_SUGGESTION_MIN_QUERY,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** User-facing names for the three mention schemes (ADR-147 D11). */
|
|
275
|
+
const MENTION_KIND_LABELS: Readonly<Record<MentionTargetType, string>> = {
|
|
276
|
+
user: 'Person',
|
|
277
|
+
task_item: 'Task',
|
|
278
|
+
task_board: 'Board',
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Close a Suggestion plugin without touching the document.
|
|
283
|
+
*
|
|
284
|
+
* This is the exact transaction `@tiptap/suggestion`'s own `exitSuggestion()`
|
|
285
|
+
* dispatches. It is re-spelled here rather than imported because that package
|
|
286
|
+
* is transitive — ADR-148 D2 deliberately did not install it, and AC#1 forbids
|
|
287
|
+
* adding it — so under pnpm's strict layout `@lovett/ui` cannot resolve it.
|
|
288
|
+
* The meta also marks the range dismissed, which is what keeps the popup shut
|
|
289
|
+
* after Escape instead of reopening on the next keystroke.
|
|
290
|
+
*/
|
|
291
|
+
function exitSuggestionPlugin(view: EditorView, kind: SuggestionKind): void {
|
|
292
|
+
view.dispatch(view.state.tr.setMeta(SUGGESTION_KEYS[kind], { exit: true }))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The `:` picker's rows: the glyph, its canonical `:shortcode:`. */
|
|
296
|
+
function emojiRows(query: string): SuggestionRows {
|
|
297
|
+
const entries = searchEmoji(query)
|
|
298
|
+
return {
|
|
299
|
+
options: entries.map((entry) => ({
|
|
300
|
+
key: entry.name,
|
|
301
|
+
label: `:${entry.name}:`,
|
|
302
|
+
glyph: entry.glyph,
|
|
303
|
+
})),
|
|
304
|
+
attrs: entries.map((entry) => ({ id: entry.name, label: entry.glyph })),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The first character of a label, for the leading slot. */
|
|
309
|
+
function leadingInitial(label: string): string {
|
|
310
|
+
return ([...label][0] ?? '').toUpperCase()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The `@` picker's rows, from whatever list the host handed us (D6). */
|
|
314
|
+
function mentionRows(items: readonly MentionSuggestionItem[]): SuggestionRows {
|
|
315
|
+
return {
|
|
316
|
+
options: items.map((item) => ({
|
|
317
|
+
key: `${item.type}:${item.id}`,
|
|
318
|
+
label: item.label,
|
|
319
|
+
hint: MENTION_KIND_LABELS[item.type],
|
|
320
|
+
glyph: leadingInitial(item.label),
|
|
321
|
+
// The initial is the label's own first letter — decoration, not
|
|
322
|
+
// information. The emoji rows above deliberately do NOT set this.
|
|
323
|
+
glyphDecorative: true,
|
|
324
|
+
})),
|
|
325
|
+
attrs: [...items],
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ── Helpers ──────────────────────────────────────────────────────────────── */
|
|
330
|
+
|
|
331
|
+
function isPlainTab(event: KeyboardEvent): boolean {
|
|
332
|
+
return event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const BARE_URL = /^(?:https?:\/\/|www\.)\S+$/i
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* What the link field accepts: an http(s) / mailto / tel URL, or a bare domain
|
|
339
|
+
* that gets `https://` in front. Anything with another scheme is refused —
|
|
340
|
+
* `javascript:` above all — before it can reach `setLink`, which refuses it
|
|
341
|
+
* again by its own allowlist. Two locks, same door.
|
|
342
|
+
*/
|
|
343
|
+
function normalizeHref(input: string): string | null {
|
|
344
|
+
const trimmed = input.trim()
|
|
345
|
+
if (trimmed === '') return null
|
|
346
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
|
|
347
|
+
return /^(?:https?:|mailto:|tel:)/i.test(trimmed) ? trimmed : null
|
|
348
|
+
}
|
|
349
|
+
if (/^(?:www\.)?[\w-]+(?:\.[\w-]+)+(?:[/?#]\S*)?$/i.test(trimmed)) return `https://${trimmed}`
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A `--space-*` token as a number, for floating-ui's offset. Falls back to the token's documented value. */
|
|
354
|
+
function readSpaceToken(element: HTMLElement | null, token: string, fallback: number): number {
|
|
355
|
+
if (element === null || typeof getComputedStyle !== 'function') return fallback
|
|
356
|
+
const raw = parseFloat(getComputedStyle(element).getPropertyValue(token))
|
|
357
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function focusableButtons(root: HTMLElement | null): HTMLButtonElement[] {
|
|
361
|
+
if (root === null) return []
|
|
362
|
+
return Array.from(root.querySelectorAll<HTMLButtonElement>('button:not([disabled])'))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* ── The editor ───────────────────────────────────────────────────────────── */
|
|
366
|
+
|
|
367
|
+
interface LatestProps {
|
|
368
|
+
readonly value: string
|
|
369
|
+
readonly onChange: (value: string) => void
|
|
370
|
+
readonly onSubmit: (attachmentIds?: readonly string[]) => void
|
|
371
|
+
readonly onCancel: (() => void) | undefined
|
|
372
|
+
readonly mentionItems: ((query: string) => readonly MentionSuggestionItem[]) | undefined
|
|
373
|
+
readonly announce: ((message: string) => void) | undefined
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function RichSurface({
|
|
377
|
+
value,
|
|
378
|
+
onChange,
|
|
379
|
+
onSubmit,
|
|
380
|
+
onCancel,
|
|
381
|
+
placeholder = 'Add a comment…',
|
|
382
|
+
submitLabel = 'Comment',
|
|
383
|
+
cancelLabel = 'Cancel',
|
|
384
|
+
sending = false,
|
|
385
|
+
disabled = false,
|
|
386
|
+
replyingTo,
|
|
387
|
+
avatar,
|
|
388
|
+
rows = 3,
|
|
389
|
+
mode,
|
|
390
|
+
onModeChange,
|
|
391
|
+
autoFocus = false,
|
|
392
|
+
toolbar = true,
|
|
393
|
+
maxLength,
|
|
394
|
+
label,
|
|
395
|
+
className,
|
|
396
|
+
onEditor,
|
|
397
|
+
mentionItems,
|
|
398
|
+
announce,
|
|
399
|
+
uploadAttachment,
|
|
400
|
+
attachGif,
|
|
401
|
+
searchGifs,
|
|
402
|
+
attachmentAccept,
|
|
403
|
+
attachmentMaxPerComment,
|
|
404
|
+
attachmentMaxFileBytes,
|
|
405
|
+
attachmentMaxTotalBytes,
|
|
406
|
+
}: RichThreadComposerImplProps) {
|
|
407
|
+
const labelId = useId()
|
|
408
|
+
const hintId = useId()
|
|
409
|
+
const linkErrorId = useId()
|
|
410
|
+
const listboxId = useId()
|
|
411
|
+
const shellRef = useRef<HTMLDivElement>(null)
|
|
412
|
+
const bubbleRef = useRef<HTMLDivElement>(null)
|
|
413
|
+
const linkInputRef = useRef<HTMLInputElement>(null)
|
|
414
|
+
const [gifPickerOpen, setGifPickerOpen] = useState(false)
|
|
415
|
+
const attachments = useAttachments({
|
|
416
|
+
...(uploadAttachment === undefined ? {} : { upload: uploadAttachment }),
|
|
417
|
+
...(attachGif === undefined ? {} : { attachGif }),
|
|
418
|
+
...(announce === undefined ? {} : { onError: announce }),
|
|
419
|
+
...(attachmentMaxPerComment === undefined
|
|
420
|
+
? {}
|
|
421
|
+
: { maxPerComment: attachmentMaxPerComment }),
|
|
422
|
+
...(attachmentMaxFileBytes === undefined ? {} : { maxFileBytes: attachmentMaxFileBytes }),
|
|
423
|
+
...(attachmentMaxTotalBytes === undefined
|
|
424
|
+
? {}
|
|
425
|
+
: { maxTotalBytes: attachmentMaxTotalBytes }),
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// The editor is created once; its callbacks read the latest props through
|
|
429
|
+
// this ref rather than the closures they were created with.
|
|
430
|
+
const latest = useRef<LatestProps>({ value, onChange, onSubmit, onCancel, mentionItems, announce })
|
|
431
|
+
useLayoutEffect(() => {
|
|
432
|
+
latest.current = { value, onChange, onSubmit, onCancel, mentionItems, announce }
|
|
433
|
+
})
|
|
434
|
+
const editorRef = useRef<Editor | null>(null)
|
|
435
|
+
|
|
436
|
+
// D12 — draft emission.
|
|
437
|
+
const lastEmitted = useRef<string>(value)
|
|
438
|
+
const emitTimer = useRef<number | null>(null)
|
|
439
|
+
const [pendingSubmit, setPendingSubmit] = useState<string | null>(null)
|
|
440
|
+
|
|
441
|
+
// D7 / D9 / D11 — menu state. Refs are the truth the ProseMirror handlers
|
|
442
|
+
// read synchronously; the React state mirrors them for rendering.
|
|
443
|
+
const composing = useRef(false)
|
|
444
|
+
const bubbleOpenRef = useRef(false)
|
|
445
|
+
|
|
446
|
+
// ADR-149 — the suggestion popup. The ref is what `handleKeyDown` reads
|
|
447
|
+
// (a keystroke cannot wait for a render); the state is what draws.
|
|
448
|
+
const suggestionRef = useRef<{
|
|
449
|
+
readonly kind: SuggestionKind
|
|
450
|
+
readonly attrs: readonly SuggestionAttrs[]
|
|
451
|
+
readonly command: (attrs: SuggestionAttrs) => void
|
|
452
|
+
} | null>(null)
|
|
453
|
+
const activeIndexRef = useRef(0)
|
|
454
|
+
const [suggestion, setSuggestion] = useState<{
|
|
455
|
+
readonly kind: SuggestionKind
|
|
456
|
+
readonly container: HTMLElement
|
|
457
|
+
readonly options: readonly SuggestionOption[]
|
|
458
|
+
} | null>(null)
|
|
459
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
460
|
+
|
|
461
|
+
const setActiveOption = useCallback((index: number) => {
|
|
462
|
+
activeIndexRef.current = index
|
|
463
|
+
setActiveIndex(index)
|
|
464
|
+
}, [])
|
|
465
|
+
|
|
466
|
+
const optionDomId = useCallback(
|
|
467
|
+
(index: number) => `${listboxId}-option-${String(index)}`,
|
|
468
|
+
[listboxId],
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
/** Insert the row at `index`; the plugin's own command owns the document. */
|
|
472
|
+
const commitSuggestion = useCallback((index: number) => {
|
|
473
|
+
const active = suggestionRef.current
|
|
474
|
+
const attrs = active?.attrs[index]
|
|
475
|
+
if (active === undefined || active === null || attrs === undefined) return
|
|
476
|
+
active.command(attrs)
|
|
477
|
+
}, [])
|
|
478
|
+
const dismissedSelection = useRef<{ from: number; to: number } | null>(null)
|
|
479
|
+
const [bubbleOpen, setBubbleOpen] = useState(false)
|
|
480
|
+
const [focusRequest, setFocusRequest] = useState<'toolbar' | 'link' | null>(null)
|
|
481
|
+
const [linkMode, setLinkMode] = useState(false)
|
|
482
|
+
const [linkDraft, setLinkDraft] = useState('')
|
|
483
|
+
const [linkError, setLinkError] = useState(false)
|
|
484
|
+
const [rovingIndex, setRovingIndex] = useState(0)
|
|
485
|
+
|
|
486
|
+
const setBubble = useCallback((open: boolean) => {
|
|
487
|
+
bubbleOpenRef.current = open
|
|
488
|
+
setBubbleOpen((prev) => (prev === open ? prev : open))
|
|
489
|
+
if (!open) {
|
|
490
|
+
setLinkMode(false)
|
|
491
|
+
setLinkError(false)
|
|
492
|
+
setFocusRequest(null)
|
|
493
|
+
}
|
|
494
|
+
}, [])
|
|
495
|
+
|
|
496
|
+
/** D7: the one predicate both the plugin and the React state answer to. */
|
|
497
|
+
const shouldShowBubble = useCallback((editor: Editor): boolean => {
|
|
498
|
+
if (!editor.isEditable) return false
|
|
499
|
+
const { selection, doc } = editor.state
|
|
500
|
+
if (selection.empty || !isTextSelection(selection)) return false
|
|
501
|
+
if (doc.textBetween(selection.from, selection.to, ' ').trim() === '') return false
|
|
502
|
+
if (editor.isActive('codeBlock')) return false
|
|
503
|
+
if (editor.view.composing || composing.current) return false
|
|
504
|
+
if (suggestionRef.current !== null) return false
|
|
505
|
+
const dismissed = dismissedSelection.current
|
|
506
|
+
if (dismissed !== null) {
|
|
507
|
+
if (dismissed.from === selection.from && dismissed.to === selection.to) return false
|
|
508
|
+
dismissedSelection.current = null
|
|
509
|
+
}
|
|
510
|
+
return true
|
|
511
|
+
}, [])
|
|
512
|
+
|
|
513
|
+
const syncBubble = useCallback(
|
|
514
|
+
(editor: Editor) => {
|
|
515
|
+
setBubble(shouldShowBubble(editor))
|
|
516
|
+
},
|
|
517
|
+
[setBubble, shouldShowBubble],
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
const dismissBubble = useCallback(
|
|
521
|
+
(editor: Editor) => {
|
|
522
|
+
const { from, to } = editor.state.selection
|
|
523
|
+
dismissedSelection.current = { from, to }
|
|
524
|
+
setBubble(false)
|
|
525
|
+
editor.view.dispatch(editor.state.tr.setMeta(BUBBLE_MENU_KEY, 'hide'))
|
|
526
|
+
},
|
|
527
|
+
[setBubble],
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
const emitNow = useCallback((editor: Editor): string => {
|
|
531
|
+
const markdown = serializeRichMarkdown(editor.getJSON())
|
|
532
|
+
if (markdown !== lastEmitted.current) {
|
|
533
|
+
lastEmitted.current = markdown
|
|
534
|
+
latest.current.onChange(markdown)
|
|
535
|
+
}
|
|
536
|
+
return markdown
|
|
537
|
+
}, [])
|
|
538
|
+
|
|
539
|
+
const clearEmitTimer = useCallback(() => {
|
|
540
|
+
if (emitTimer.current !== null) {
|
|
541
|
+
window.clearTimeout(emitTimer.current)
|
|
542
|
+
emitTimer.current = null
|
|
543
|
+
}
|
|
544
|
+
}, [])
|
|
545
|
+
|
|
546
|
+
const scheduleEmit = useCallback(
|
|
547
|
+
(editor: Editor) => {
|
|
548
|
+
clearEmitTimer()
|
|
549
|
+
emitTimer.current = window.setTimeout(() => {
|
|
550
|
+
emitTimer.current = null
|
|
551
|
+
if (!editor.isDestroyed) emitNow(editor)
|
|
552
|
+
}, DRAFT_DEBOUNCE_MS)
|
|
553
|
+
},
|
|
554
|
+
[clearEmitTimer, emitNow],
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
const flushEmit = useCallback(
|
|
558
|
+
(editor: Editor): string => {
|
|
559
|
+
clearEmitTimer()
|
|
560
|
+
return emitNow(editor)
|
|
561
|
+
},
|
|
562
|
+
[clearEmitTimer, emitNow],
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
const requestSubmit = useCallback(
|
|
566
|
+
(editor: Editor) => {
|
|
567
|
+
if (editor.isEmpty) return
|
|
568
|
+
const markdown = flushEmit(editor)
|
|
569
|
+
setPendingSubmit(markdown)
|
|
570
|
+
},
|
|
571
|
+
[flushEmit],
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* D8 — pasted text. A single URL becomes a link whose text is the URL; text
|
|
576
|
+
* that parses within the subset is parsed as markdown; anything else — a
|
|
577
|
+
* plain paste (Shift), or markdown holding a table, raw HTML, an h4 — lands
|
|
578
|
+
* as literal paragraphs so no character is dropped.
|
|
579
|
+
*/
|
|
580
|
+
const parsePastedText = useCallback(
|
|
581
|
+
(text: string, plain: boolean, view: EditorView): Slice => {
|
|
582
|
+
const { schema } = view.state
|
|
583
|
+
const trimmed = text.trim()
|
|
584
|
+
const linkType = schema.marks['link']
|
|
585
|
+
const paragraphType = schema.nodes['paragraph']
|
|
586
|
+
if (linkType !== undefined && BARE_URL.test(trimmed)) {
|
|
587
|
+
const href = /^www\./i.test(trimmed) ? `https://${trimmed}` : trimmed
|
|
588
|
+
return new Slice(Fragment.from(schema.text(trimmed, [linkType.create({ href })])), 0, 0)
|
|
589
|
+
}
|
|
590
|
+
if (!plain && isRichEditable(text)) {
|
|
591
|
+
try {
|
|
592
|
+
const doc = ProseMirrorNode.fromJSON(schema, parseRichMarkdown(text))
|
|
593
|
+
doc.check()
|
|
594
|
+
return Slice.maxOpen(doc.content)
|
|
595
|
+
} catch {
|
|
596
|
+
// Fall through to literal text; nothing is dropped either way.
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (paragraphType === undefined) return Slice.empty
|
|
600
|
+
const paragraphs = text
|
|
601
|
+
.split(/\r\n?|\n/)
|
|
602
|
+
.map((line) => paragraphType.create(null, line === '' ? null : schema.text(line)))
|
|
603
|
+
return Slice.maxOpen(Fragment.from(paragraphs))
|
|
604
|
+
},
|
|
605
|
+
[],
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* One `render()` per trigger, both drawing the same `<SuggestionList>`
|
|
610
|
+
* (ADR-149 D1). The closure per plugin owns exactly two things: the portal
|
|
611
|
+
* element the popup lives in, and the `unmount` that floating-ui hands back.
|
|
612
|
+
*
|
|
613
|
+
* The rows are recomputed here from `props.query` rather than read off
|
|
614
|
+
* `props.items`, because `@tiptap/suggestion` types its items as `any` and
|
|
615
|
+
* this file does not take `any` (CLAUDE.md §3). The plugin still owns the
|
|
616
|
+
* threshold — below `minQueryLength` it reports an empty query's worth of
|
|
617
|
+
* items and we close, which is the same door "no matches" comes through.
|
|
618
|
+
*/
|
|
619
|
+
const makeSuggestionRenderer = useCallback(
|
|
620
|
+
(kind: SuggestionKind) => {
|
|
621
|
+
let container: HTMLElement | null = null
|
|
622
|
+
let unmount: (() => void) | null = null
|
|
623
|
+
let lastQuery: string | null = null
|
|
624
|
+
|
|
625
|
+
const rowsFor = (query: string): SuggestionRows => {
|
|
626
|
+
// D4: a bare sigil is punctuation, not a trigger.
|
|
627
|
+
if (query.length < SUGGESTION_MIN_QUERY[kind]) return EMPTY_ROWS
|
|
628
|
+
if (kind === 'emoji') return emojiRows(query)
|
|
629
|
+
const items = latest.current.mentionItems?.(query)
|
|
630
|
+
return items === undefined ? EMPTY_ROWS : mentionRows(items)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const close = () => {
|
|
634
|
+
if (unmount !== null) {
|
|
635
|
+
unmount()
|
|
636
|
+
unmount = null
|
|
637
|
+
}
|
|
638
|
+
container = null
|
|
639
|
+
lastQuery = null
|
|
640
|
+
if (suggestionRef.current?.kind === kind) suggestionRef.current = null
|
|
641
|
+
setSuggestion((prev) => (prev !== null && prev.kind === kind ? null : prev))
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const sync = (props: SuggestionRenderProps) => {
|
|
645
|
+
const rows = rowsFor(props.query)
|
|
646
|
+
// D4: nothing to show is one of the four things that mean "no".
|
|
647
|
+
if (rows.options.length === 0) {
|
|
648
|
+
close()
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
const opening = container === null
|
|
652
|
+
let element = container
|
|
653
|
+
if (element === null) {
|
|
654
|
+
element = document.createElement('div')
|
|
655
|
+
element.className = 'ds-thread-suggestions-anchor'
|
|
656
|
+
container = element
|
|
657
|
+
// The plugin owns anchoring — it re-reads the caret rect on scroll,
|
|
658
|
+
// resize and layout shift. `onPosition` is what keeps it from
|
|
659
|
+
// hiding the element until it has measured, which would make the
|
|
660
|
+
// popup invisible to a jsdom role query for no benefit.
|
|
661
|
+
const anchored = element
|
|
662
|
+
unmount = props.mount(element, {
|
|
663
|
+
onPosition: ({ x, y, strategy }) => {
|
|
664
|
+
anchored.style.position = strategy
|
|
665
|
+
anchored.style.left = `${String(x)}px`
|
|
666
|
+
anchored.style.top = `${String(y)}px`
|
|
667
|
+
},
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
// The plugin dispatches `started` and `updated` for one keystroke;
|
|
671
|
+
// redrawing the same query would also reset the active option under a
|
|
672
|
+
// user who had already arrowed down. The ref is refreshed on the SAME
|
|
673
|
+
// side of this gate as the drawn rows, deliberately: `attrs` is what
|
|
674
|
+
// an index resolves against, so letting it move while the options on
|
|
675
|
+
// screen stay put would let Enter insert a row nobody was looking at.
|
|
676
|
+
if (!opening && lastQuery === props.query) return
|
|
677
|
+
suggestionRef.current = { kind, attrs: rows.attrs, command: props.command }
|
|
678
|
+
lastQuery = props.query
|
|
679
|
+
setActiveOption(0)
|
|
680
|
+
setBubble(false)
|
|
681
|
+
setSuggestion({ kind, container: element, options: rows.options })
|
|
682
|
+
// A `role="textbox"` cannot carry `aria-expanded` (ARIA reserves it
|
|
683
|
+
// for combobox), so nothing in the markup tells a screen-reader user
|
|
684
|
+
// the popup appeared. One polite line at OPEN does — once per
|
|
685
|
+
// session, not once per keystroke, because a count that re-announces
|
|
686
|
+
// on every letter is worse than no count at all.
|
|
687
|
+
if (opening) {
|
|
688
|
+
latest.current.announce?.(
|
|
689
|
+
`${String(rows.options.length)} ${SUGGESTION_NOUNS[kind]} suggestions. Use the arrow keys to choose, Enter to insert, Escape to close.`,
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
onStart: sync,
|
|
696
|
+
onUpdate: sync,
|
|
697
|
+
onExit: close,
|
|
698
|
+
// Escape, the arrows, Enter and Tab are all answered in
|
|
699
|
+
// `handleKeyDown` below — `editorProps` handlers run before any
|
|
700
|
+
// plugin's, which is what makes ADR-148 D9's Escape order a fact.
|
|
701
|
+
onKeyDown: () => false,
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
[setActiveOption, setBubble],
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
const editor = useEditor(
|
|
708
|
+
{
|
|
709
|
+
extensions: [
|
|
710
|
+
...richSubsetExtensions({
|
|
711
|
+
mention: { render: () => makeSuggestionRenderer('mention') },
|
|
712
|
+
emoji: { render: () => makeSuggestionRenderer('emoji') },
|
|
713
|
+
}),
|
|
714
|
+
Placeholder.configure({ placeholder }),
|
|
715
|
+
],
|
|
716
|
+
content: value,
|
|
717
|
+
contentType: 'markdown',
|
|
718
|
+
editable: !disabled,
|
|
719
|
+
autofocus: autoFocus ? 'end' : false,
|
|
720
|
+
// D8 is implemented once, in `clipboardTextParser` below. TipTap's
|
|
721
|
+
// regex paste rules (bold, italic, strike, code, …) would run a second,
|
|
722
|
+
// looser pass over the pasted text — and re-format a Shift-paste that
|
|
723
|
+
// was meant to stay literal. Input rules (typing `**x**`, `- `, `---`)
|
|
724
|
+
// are unaffected.
|
|
725
|
+
enablePasteRules: false,
|
|
726
|
+
editorProps: {
|
|
727
|
+
/**
|
|
728
|
+
* THE fix for "the entire viewport is scrolling down" on Enter.
|
|
729
|
+
*
|
|
730
|
+
* ProseMirror scrolls the selection into view after any transaction
|
|
731
|
+
* that asks for it — every Enter, every arrow key, most typing. Its
|
|
732
|
+
* `scrollRectIntoView` walks UP from the editor and scrolls *every*
|
|
733
|
+
* scrollable ancestor it finds until the caret is visible: the
|
|
734
|
+
* thread's own body, then `PageShell`'s content region, then whatever
|
|
735
|
+
* else. Capping the editor's height does not stop that; it only
|
|
736
|
+
* changes which ancestor moves. The ancestors have to be taken out of
|
|
737
|
+
* the walk entirely, and this hook is the only supported way to do it.
|
|
738
|
+
*
|
|
739
|
+
* Returning `true` means "handled — do not run the default". We then
|
|
740
|
+
* do the one scroll that is legitimate: bring the caret into view
|
|
741
|
+
* inside the EDITOR, and touch nothing else. `scrollTop` on a single
|
|
742
|
+
* element cannot chain.
|
|
743
|
+
*/
|
|
744
|
+
handleScrollToSelection: (view) => {
|
|
745
|
+
const dom = view.dom as HTMLElement
|
|
746
|
+
const head = view.state.selection.head
|
|
747
|
+
let caret: { top: number; bottom: number }
|
|
748
|
+
try {
|
|
749
|
+
caret = view.coordsAtPos(head)
|
|
750
|
+
} catch {
|
|
751
|
+
// A position with no coords yet (mid-reconfigure). Nothing to
|
|
752
|
+
// scroll to, and the default would be no better.
|
|
753
|
+
return true
|
|
754
|
+
}
|
|
755
|
+
const box = dom.getBoundingClientRect()
|
|
756
|
+
// One line of slack so the caret is never flush against the edge.
|
|
757
|
+
const pad = parseFloat(getComputedStyle(dom).lineHeight) || 0
|
|
758
|
+
dom.scrollTop += caretScrollDelta(box, caret, pad)
|
|
759
|
+
return true
|
|
760
|
+
},
|
|
761
|
+
attributes: {
|
|
762
|
+
role: 'textbox',
|
|
763
|
+
'aria-multiline': 'true',
|
|
764
|
+
'aria-labelledby': labelId,
|
|
765
|
+
'aria-describedby': hintId,
|
|
766
|
+
},
|
|
767
|
+
handleDOMEvents: {
|
|
768
|
+
// D9: Tab is never captured. Returning true here skips ProseMirror's
|
|
769
|
+
// own keydown handling (and every keymap) without preventDefault, so
|
|
770
|
+
// the browser moves focus. ADR-149 D5 is the one exception: while a
|
|
771
|
+
// suggestion popup is open Tab INSERTS, so this stands down and
|
|
772
|
+
// `handleKeyDown` answers it (and does preventDefault, so focus
|
|
773
|
+
// stays where it already is — on the editor).
|
|
774
|
+
keydown: (_view, event) => isPlainTab(event) && suggestionRef.current === null,
|
|
775
|
+
compositionstart: () => {
|
|
776
|
+
composing.current = true
|
|
777
|
+
setBubble(false)
|
|
778
|
+
return false
|
|
779
|
+
},
|
|
780
|
+
compositionend: () => {
|
|
781
|
+
composing.current = false
|
|
782
|
+
return false
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
handleKeyDown: (view, event) => {
|
|
786
|
+
const editorFromView = editorRef.current
|
|
787
|
+
if (editorFromView === null) return false
|
|
788
|
+
const mod = event.metaKey || event.ctrlKey
|
|
789
|
+
if (event.key === 'Escape') {
|
|
790
|
+
if (bubbleOpenRef.current) {
|
|
791
|
+
dismissBubble(editorFromView)
|
|
792
|
+
return true
|
|
793
|
+
}
|
|
794
|
+
const openSuggestion = suggestionRef.current
|
|
795
|
+
if (openSuggestion !== null) {
|
|
796
|
+
// D5 / AC#7: close WITHOUT inserting, and do not fall through to
|
|
797
|
+
// the reply composer's cancel — that is the third slot, not the
|
|
798
|
+
// second. The plugin also records the range as dismissed, so the
|
|
799
|
+
// popup does not spring back on the next keystroke.
|
|
800
|
+
exitSuggestionPlugin(view, openSuggestion.kind)
|
|
801
|
+
return true
|
|
802
|
+
}
|
|
803
|
+
const cancel = latest.current.onCancel
|
|
804
|
+
if (cancel !== undefined) {
|
|
805
|
+
cancel()
|
|
806
|
+
return true
|
|
807
|
+
}
|
|
808
|
+
return false
|
|
809
|
+
}
|
|
810
|
+
if (mod && event.key === 'Enter') {
|
|
811
|
+
requestSubmit(editorFromView)
|
|
812
|
+
return true
|
|
813
|
+
}
|
|
814
|
+
// ADR-149 D5 — the popup's keys, intercepted before the editor's own
|
|
815
|
+
// keymap. Every one of them is unmodified: Cmd+Enter above still
|
|
816
|
+
// sends, and Shift+Enter still makes a hard break.
|
|
817
|
+
const navigating = suggestionRef.current
|
|
818
|
+
if (navigating !== null && !mod && !event.altKey && !event.shiftKey) {
|
|
819
|
+
const count = navigating.attrs.length
|
|
820
|
+
if (event.key === 'ArrowDown') {
|
|
821
|
+
setActiveOption(wrapIndex(activeIndexRef.current, count, 1))
|
|
822
|
+
return true
|
|
823
|
+
}
|
|
824
|
+
if (event.key === 'ArrowUp') {
|
|
825
|
+
setActiveOption(wrapIndex(activeIndexRef.current, count, -1))
|
|
826
|
+
return true
|
|
827
|
+
}
|
|
828
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
829
|
+
commitSuggestion(activeIndexRef.current)
|
|
830
|
+
return true
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (mod && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'x') {
|
|
834
|
+
editorFromView.chain().focus().toggleStrike().run()
|
|
835
|
+
return true
|
|
836
|
+
}
|
|
837
|
+
if (mod && event.key === '.') {
|
|
838
|
+
if (!shouldShowBubble(editorFromView)) return false
|
|
839
|
+
dismissedSelection.current = null
|
|
840
|
+
setBubble(true)
|
|
841
|
+
setLinkMode(false)
|
|
842
|
+
setRovingIndex(0)
|
|
843
|
+
setFocusRequest('toolbar')
|
|
844
|
+
return true
|
|
845
|
+
}
|
|
846
|
+
if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
|
|
847
|
+
if (editorFromView.isActive('link') && view.state.selection.empty) {
|
|
848
|
+
editorFromView.commands.extendMarkRange('link')
|
|
849
|
+
}
|
|
850
|
+
if (!shouldShowBubble(editorFromView)) return true
|
|
851
|
+
dismissedSelection.current = null
|
|
852
|
+
setBubble(true)
|
|
853
|
+
setLinkDraft(readHref(editorFromView))
|
|
854
|
+
setLinkError(false)
|
|
855
|
+
setLinkMode(true)
|
|
856
|
+
setFocusRequest('link')
|
|
857
|
+
return true
|
|
858
|
+
}
|
|
859
|
+
return false
|
|
860
|
+
},
|
|
861
|
+
handlePaste: (_view, event) => {
|
|
862
|
+
const dt = event.clipboardData
|
|
863
|
+
if (!dt || dt.files.length > 0 || dt.types.includes('text/html')) return false
|
|
864
|
+
return attachments.addPastedText(dt.getData('text/plain'))
|
|
865
|
+
},
|
|
866
|
+
clipboardTextParser: (text, _context, plain, view) => parsePastedText(text, plain, view),
|
|
867
|
+
},
|
|
868
|
+
onCreate: ({ editor: created }) => {
|
|
869
|
+
editorRef.current = created
|
|
870
|
+
},
|
|
871
|
+
onUpdate: ({ editor: updated }) => {
|
|
872
|
+
setPendingSubmit(null)
|
|
873
|
+
scheduleEmit(updated)
|
|
874
|
+
syncBubble(updated)
|
|
875
|
+
},
|
|
876
|
+
onSelectionUpdate: ({ editor: updated }) => {
|
|
877
|
+
syncBubble(updated)
|
|
878
|
+
},
|
|
879
|
+
onFocus: ({ editor: focused }) => {
|
|
880
|
+
syncBubble(focused)
|
|
881
|
+
},
|
|
882
|
+
onBlur: ({ editor: blurred, event }) => {
|
|
883
|
+
flushEmit(blurred)
|
|
884
|
+
const next = event.relatedTarget
|
|
885
|
+
const insideMenu = next instanceof Node && bubbleRef.current?.contains(next) === true
|
|
886
|
+
if (!insideMenu) setBubble(false)
|
|
887
|
+
// D4: blur is one of the four things that close the popup. Clicking an
|
|
888
|
+
// option cannot reach here — the option's `mousedown` is prevented, so
|
|
889
|
+
// the editor never loses focus (D5, AC#6).
|
|
890
|
+
const openSuggestion = suggestionRef.current
|
|
891
|
+
if (openSuggestion !== null) exitSuggestionPlugin(blurred.view, openSuggestion.kind)
|
|
892
|
+
},
|
|
893
|
+
},
|
|
894
|
+
[],
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
useLayoutEffect(() => {
|
|
898
|
+
editorRef.current = editor
|
|
899
|
+
}, [editor])
|
|
900
|
+
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
onEditor?.(editor)
|
|
903
|
+
return () => {
|
|
904
|
+
onEditor?.(null)
|
|
905
|
+
}
|
|
906
|
+
}, [editor, onEditor])
|
|
907
|
+
|
|
908
|
+
useEffect(() => {
|
|
909
|
+
editor.setEditable(!disabled)
|
|
910
|
+
}, [disabled, editor])
|
|
911
|
+
|
|
912
|
+
const insertPastedText = useCallback(
|
|
913
|
+
(localId: string) => {
|
|
914
|
+
const text = attachments.insertAsText(localId)
|
|
915
|
+
if (text === null) return
|
|
916
|
+
const slice = parsePastedText(text, true, editor.view)
|
|
917
|
+
editor.view.dispatch(editor.state.tr.replaceSelection(slice).scrollIntoView())
|
|
918
|
+
editor.commands.focus()
|
|
919
|
+
},
|
|
920
|
+
[attachments, editor, parsePastedText],
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
// An external `value` — a resumed draft, a reset after send — replaces the
|
|
924
|
+
// document. Our own emissions never come back through here, and a pending
|
|
925
|
+
// emission is dropped rather than allowed to resurrect what was just replaced.
|
|
926
|
+
useEffect(() => {
|
|
927
|
+
if (value === lastEmitted.current) return
|
|
928
|
+
clearEmitTimer()
|
|
929
|
+
lastEmitted.current = value
|
|
930
|
+
editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false })
|
|
931
|
+
}, [clearEmitTimer, editor, value])
|
|
932
|
+
|
|
933
|
+
// Send fires once the parent holds what was flushed AND every upload has a
|
|
934
|
+
// verified ticket. Uploading never disables Send: pressing it records the
|
|
935
|
+
// intent here, then this effect finishes the post when the last ticket lands.
|
|
936
|
+
useEffect(() => {
|
|
937
|
+
if (pendingSubmit === null || value !== pendingSubmit || attachments.isUploading) return
|
|
938
|
+
if (attachments.attachments.some((attachment) => attachment.state !== 'ready')) return
|
|
939
|
+
setPendingSubmit(null)
|
|
940
|
+
onSubmit(attachments.ticketIds.length === 0 ? undefined : attachments.ticketIds)
|
|
941
|
+
attachments.clear()
|
|
942
|
+
}, [attachments, onSubmit, pendingSubmit, value])
|
|
943
|
+
|
|
944
|
+
// Unmount: keystrokes still inside the debounce window reach the draft —
|
|
945
|
+
// unless the parent moved on in the meantime, in which case they are stale
|
|
946
|
+
// and must not clobber what replaced them.
|
|
947
|
+
useEffect(
|
|
948
|
+
() => () => {
|
|
949
|
+
if (emitTimer.current === null) return
|
|
950
|
+
window.clearTimeout(emitTimer.current)
|
|
951
|
+
emitTimer.current = null
|
|
952
|
+
if (latest.current.value === lastEmitted.current && !editor.isDestroyed) emitNow(editor)
|
|
953
|
+
},
|
|
954
|
+
[editor, emitNow],
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
// D10 — the counter's polite announcement at 90 %, once per crossing.
|
|
958
|
+
const announced = useRef(false)
|
|
959
|
+
useEffect(() => {
|
|
960
|
+
if (maxLength === undefined) return
|
|
961
|
+
const over = value.length > maxLength * 0.9
|
|
962
|
+
if (over && !announced.current) {
|
|
963
|
+
announced.current = true
|
|
964
|
+
announce?.(
|
|
965
|
+
`Approaching the comment length limit: ${String(value.length)} of ${String(maxLength)} characters`,
|
|
966
|
+
)
|
|
967
|
+
} else if (!over) {
|
|
968
|
+
announced.current = false
|
|
969
|
+
}
|
|
970
|
+
}, [announce, maxLength, value.length])
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* D5 — the editor is the accessible OWNER of the popup. It keeps DOM focus,
|
|
974
|
+
* points at the listbox with `aria-controls`, and names the active option
|
|
975
|
+
* with `aria-activedescendant`; nothing inside the popup is ever focused.
|
|
976
|
+
*
|
|
977
|
+
* Written straight onto the contenteditable because `editorProps.attributes`
|
|
978
|
+
* are fixed when the editor is created. That is safe: ProseMirror's document
|
|
979
|
+
* decoration only removes attribute names IT set on a previous pass, so a
|
|
980
|
+
* name it has never seen survives every transaction.
|
|
981
|
+
*/
|
|
982
|
+
useLayoutEffect(() => {
|
|
983
|
+
if (editor.isDestroyed) return
|
|
984
|
+
const dom = editor.view.dom
|
|
985
|
+
if (suggestion === null) {
|
|
986
|
+
dom.removeAttribute('aria-controls')
|
|
987
|
+
dom.removeAttribute('aria-activedescendant')
|
|
988
|
+
return
|
|
989
|
+
}
|
|
990
|
+
dom.setAttribute('aria-controls', listboxId)
|
|
991
|
+
dom.setAttribute('aria-activedescendant', optionDomId(activeIndex))
|
|
992
|
+
}, [activeIndex, editor, listboxId, optionDomId, suggestion])
|
|
993
|
+
|
|
994
|
+
// Keyboard-opened menu (D7): focus lands on the first button or the link field.
|
|
995
|
+
useEffect(() => {
|
|
996
|
+
if (focusRequest === null || !bubbleOpen) return
|
|
997
|
+
const target =
|
|
998
|
+
focusRequest === 'link' ? linkInputRef.current : focusableButtons(bubbleRef.current)[0]
|
|
999
|
+
if (target instanceof HTMLElement) {
|
|
1000
|
+
target.focus()
|
|
1001
|
+
setFocusRequest(null)
|
|
1002
|
+
}
|
|
1003
|
+
}, [bubbleOpen, focusRequest, linkMode])
|
|
1004
|
+
|
|
1005
|
+
const active = useEditorState({
|
|
1006
|
+
editor,
|
|
1007
|
+
selector: ({ editor: current }) => ({
|
|
1008
|
+
isEmpty: current.isEmpty,
|
|
1009
|
+
bold: current.isActive('bold'),
|
|
1010
|
+
italic: current.isActive('italic'),
|
|
1011
|
+
underline: current.isActive('underline'),
|
|
1012
|
+
strike: current.isActive('strike'),
|
|
1013
|
+
code: current.isActive('code'),
|
|
1014
|
+
link: current.isActive('link'),
|
|
1015
|
+
h1: current.isActive('heading', { level: 1 }),
|
|
1016
|
+
h2: current.isActive('heading', { level: 2 }),
|
|
1017
|
+
h3: current.isActive('heading', { level: 3 }),
|
|
1018
|
+
bulletList: current.isActive('bulletList'),
|
|
1019
|
+
orderedList: current.isActive('orderedList'),
|
|
1020
|
+
blockquote: current.isActive('blockquote'),
|
|
1021
|
+
codeBlock: current.isActive('codeBlock'),
|
|
1022
|
+
}),
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
const overLimit = maxLength !== undefined && value.length > maxLength
|
|
1026
|
+
const canSend =
|
|
1027
|
+
!active.isEmpty &&
|
|
1028
|
+
!sending &&
|
|
1029
|
+
!disabled &&
|
|
1030
|
+
!overLimit &&
|
|
1031
|
+
pendingSubmit === null &&
|
|
1032
|
+
!attachments.attachments.some((attachment) => attachment.state === 'failed')
|
|
1033
|
+
|
|
1034
|
+
/* ── Bubble menu actions ───────────────────────────────────────────────── */
|
|
1035
|
+
|
|
1036
|
+
const openLinkField = useCallback(() => {
|
|
1037
|
+
setLinkDraft(readHref(editor))
|
|
1038
|
+
setLinkError(false)
|
|
1039
|
+
setLinkMode(true)
|
|
1040
|
+
setFocusRequest('link')
|
|
1041
|
+
}, [editor])
|
|
1042
|
+
|
|
1043
|
+
const applyLink = useCallback(() => {
|
|
1044
|
+
const href = normalizeHref(linkDraft)
|
|
1045
|
+
if (href === null) {
|
|
1046
|
+
setLinkError(true)
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
const applied = editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
|
1050
|
+
if (!applied) {
|
|
1051
|
+
setLinkError(true)
|
|
1052
|
+
return
|
|
1053
|
+
}
|
|
1054
|
+
setLinkMode(false)
|
|
1055
|
+
setLinkError(false)
|
|
1056
|
+
}, [editor, linkDraft])
|
|
1057
|
+
|
|
1058
|
+
const removeLink = useCallback(() => {
|
|
1059
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
1060
|
+
setLinkMode(false)
|
|
1061
|
+
setLinkError(false)
|
|
1062
|
+
}, [editor])
|
|
1063
|
+
|
|
1064
|
+
const closeLinkField = useCallback(() => {
|
|
1065
|
+
setLinkMode(false)
|
|
1066
|
+
setLinkError(false)
|
|
1067
|
+
setRovingIndex(BUBBLE_ACTIONS.length - 1)
|
|
1068
|
+
setFocusRequest('toolbar')
|
|
1069
|
+
}, [])
|
|
1070
|
+
|
|
1071
|
+
/** Roving focus across the bubble toolbar; Escape returns to the editor. */
|
|
1072
|
+
const onToolbarKeyDown = useCallback(
|
|
1073
|
+
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
1074
|
+
const buttons = focusableButtons(event.currentTarget)
|
|
1075
|
+
const current = buttons.findIndex((button) => button === document.activeElement)
|
|
1076
|
+
const move = (index: number) => {
|
|
1077
|
+
const next = buttons[(index + buttons.length) % buttons.length]
|
|
1078
|
+
if (next === undefined) return
|
|
1079
|
+
setRovingIndex(buttons.indexOf(next))
|
|
1080
|
+
next.focus()
|
|
1081
|
+
}
|
|
1082
|
+
switch (event.key) {
|
|
1083
|
+
case 'ArrowRight':
|
|
1084
|
+
case 'ArrowDown':
|
|
1085
|
+
event.preventDefault()
|
|
1086
|
+
move(current + 1)
|
|
1087
|
+
return
|
|
1088
|
+
case 'ArrowLeft':
|
|
1089
|
+
case 'ArrowUp':
|
|
1090
|
+
event.preventDefault()
|
|
1091
|
+
move(current - 1)
|
|
1092
|
+
return
|
|
1093
|
+
case 'Home':
|
|
1094
|
+
event.preventDefault()
|
|
1095
|
+
move(0)
|
|
1096
|
+
return
|
|
1097
|
+
case 'End':
|
|
1098
|
+
event.preventDefault()
|
|
1099
|
+
move(buttons.length - 1)
|
|
1100
|
+
return
|
|
1101
|
+
case 'Escape':
|
|
1102
|
+
event.preventDefault()
|
|
1103
|
+
event.stopPropagation()
|
|
1104
|
+
dismissBubble(editor)
|
|
1105
|
+
editor.commands.focus()
|
|
1106
|
+
return
|
|
1107
|
+
default:
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
[dismissBubble, editor],
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
const onLinkFieldKeyDown = useCallback(
|
|
1115
|
+
(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
1116
|
+
if (event.key === 'Enter') {
|
|
1117
|
+
event.preventDefault()
|
|
1118
|
+
applyLink()
|
|
1119
|
+
} else if (event.key === 'Escape') {
|
|
1120
|
+
event.preventDefault()
|
|
1121
|
+
event.stopPropagation()
|
|
1122
|
+
closeLinkField()
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
[applyLink, closeLinkField],
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
const bubbleActive: Readonly<Record<BubbleAction, boolean>> = {
|
|
1129
|
+
bold: active.bold,
|
|
1130
|
+
italic: active.italic,
|
|
1131
|
+
underline: active.underline,
|
|
1132
|
+
strike: active.strike,
|
|
1133
|
+
code: active.code,
|
|
1134
|
+
link: active.link,
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const runBubbleAction = useCallback(
|
|
1138
|
+
(action: BubbleAction) => {
|
|
1139
|
+
switch (action) {
|
|
1140
|
+
case 'bold':
|
|
1141
|
+
editor.chain().focus().toggleBold().run()
|
|
1142
|
+
return
|
|
1143
|
+
case 'italic':
|
|
1144
|
+
editor.chain().focus().toggleItalic().run()
|
|
1145
|
+
return
|
|
1146
|
+
case 'underline':
|
|
1147
|
+
editor.chain().focus().toggleUnderline().run()
|
|
1148
|
+
return
|
|
1149
|
+
case 'strike':
|
|
1150
|
+
editor.chain().focus().toggleStrike().run()
|
|
1151
|
+
return
|
|
1152
|
+
case 'code':
|
|
1153
|
+
editor.chain().focus().toggleCode().run()
|
|
1154
|
+
return
|
|
1155
|
+
case 'link':
|
|
1156
|
+
openLinkField()
|
|
1157
|
+
return
|
|
1158
|
+
}
|
|
1159
|
+
},
|
|
1160
|
+
[editor, openLinkField],
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
const runBlockAction = useCallback(
|
|
1164
|
+
(action: BlockAction) => {
|
|
1165
|
+
const chain = editor.chain().focus()
|
|
1166
|
+
switch (action) {
|
|
1167
|
+
case 'h1':
|
|
1168
|
+
chain.toggleHeading({ level: 1 }).run()
|
|
1169
|
+
return
|
|
1170
|
+
case 'h2':
|
|
1171
|
+
chain.toggleHeading({ level: 2 }).run()
|
|
1172
|
+
return
|
|
1173
|
+
case 'h3':
|
|
1174
|
+
chain.toggleHeading({ level: 3 }).run()
|
|
1175
|
+
return
|
|
1176
|
+
case 'bulletList':
|
|
1177
|
+
chain.toggleBulletList().run()
|
|
1178
|
+
return
|
|
1179
|
+
case 'orderedList':
|
|
1180
|
+
chain.toggleOrderedList().run()
|
|
1181
|
+
return
|
|
1182
|
+
case 'blockquote':
|
|
1183
|
+
chain.toggleBlockquote().run()
|
|
1184
|
+
return
|
|
1185
|
+
case 'codeBlock':
|
|
1186
|
+
chain.toggleCodeBlock().run()
|
|
1187
|
+
return
|
|
1188
|
+
case 'divider':
|
|
1189
|
+
chain.setHorizontalRule().run()
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
[editor],
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
const blockActive: Readonly<Record<BlockAction, boolean>> = {
|
|
1197
|
+
h1: active.h1,
|
|
1198
|
+
h2: active.h2,
|
|
1199
|
+
h3: active.h3,
|
|
1200
|
+
bulletList: active.bulletList,
|
|
1201
|
+
orderedList: active.orderedList,
|
|
1202
|
+
blockquote: active.blockquote,
|
|
1203
|
+
codeBlock: active.codeBlock,
|
|
1204
|
+
divider: false,
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const shellStyle: StyleWithVars = {
|
|
1208
|
+
// Concentric: the host panel declares --thread-inner-radius as its own
|
|
1209
|
+
// radius minus its padding. Nothing here eyeballs a corner.
|
|
1210
|
+
borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
|
|
1211
|
+
borderColor: 'rgb(var(--border))',
|
|
1212
|
+
background: 'rgb(var(--surface-card))',
|
|
1213
|
+
'--composer-rows': String(rows),
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const controlsDisabled = disabled || sending || pendingSubmit !== null
|
|
1217
|
+
|
|
1218
|
+
return (
|
|
1219
|
+
<div className={cn('flex flex-col gap-[var(--space-1)]', className)}>
|
|
1220
|
+
{replyingTo === undefined ? null : (
|
|
1221
|
+
<p
|
|
1222
|
+
className="flex items-center gap-[var(--space-1)] text-[12px]"
|
|
1223
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
1224
|
+
>
|
|
1225
|
+
<CornerDownRight size={10} strokeWidth={1.5} aria-hidden="true" />
|
|
1226
|
+
Replying to{' '}
|
|
1227
|
+
<span className="font-semibold" style={{ color: 'rgb(var(--text-secondary))' }}>
|
|
1228
|
+
{replyingTo}
|
|
1229
|
+
</span>
|
|
1230
|
+
</p>
|
|
1231
|
+
)}
|
|
1232
|
+
<div className="flex items-start gap-[var(--space-2)]">
|
|
1233
|
+
{avatar}
|
|
1234
|
+
<AttachmentDropZone
|
|
1235
|
+
onFiles={attachments.addFiles}
|
|
1236
|
+
disabled={controlsDisabled || uploadAttachment === undefined}
|
|
1237
|
+
className="min-w-0 flex-1"
|
|
1238
|
+
>
|
|
1239
|
+
<div
|
|
1240
|
+
ref={shellRef}
|
|
1241
|
+
className={cn(
|
|
1242
|
+
'ds-thread-rich-composer relative min-w-0 w-full border',
|
|
1243
|
+
'transition-[border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
1244
|
+
'motion-reduce:transition-none',
|
|
1245
|
+
'focus-within:border-[rgb(var(--accent))] focus-within:[box-shadow:var(--ring-focus)]',
|
|
1246
|
+
)}
|
|
1247
|
+
style={shellStyle}
|
|
1248
|
+
>
|
|
1249
|
+
{/* A contenteditable has no implicit name; these two are what the
|
|
1250
|
+
textbox points at (D10). */}
|
|
1251
|
+
<span id={labelId} className="sr-only">
|
|
1252
|
+
{label}
|
|
1253
|
+
</span>
|
|
1254
|
+
<span id={hintId} className="sr-only">
|
|
1255
|
+
{`${MOD_NAME}+Enter sends. ${MOD_NAME}+Period opens the formatting menu on a selection. Type @ to mention someone, or a colon and two letters for emoji, then use the arrow keys and Enter. Escape closes the menu${onCancel === undefined ? '' : ', then cancels the reply'}.`}
|
|
1256
|
+
</span>
|
|
1257
|
+
|
|
1258
|
+
<EditorContent editor={editor} />
|
|
1259
|
+
|
|
1260
|
+
{/* ADR-149 — ONE popup for both triggers. It is portalled into the
|
|
1261
|
+
element the Suggestion plugin mounts and anchors (floating-ui,
|
|
1262
|
+
already a dependency of that plugin), so it escapes the thread's
|
|
1263
|
+
own scroll clipping. It is still the editor's, through
|
|
1264
|
+
`aria-controls` above. */}
|
|
1265
|
+
{suggestion === null
|
|
1266
|
+
? null
|
|
1267
|
+
: createPortal(
|
|
1268
|
+
<SuggestionList
|
|
1269
|
+
id={listboxId}
|
|
1270
|
+
label={SUGGESTION_LABELS[suggestion.kind]}
|
|
1271
|
+
options={suggestion.options}
|
|
1272
|
+
activeIndex={activeIndex}
|
|
1273
|
+
optionId={optionDomId}
|
|
1274
|
+
onSelect={commitSuggestion}
|
|
1275
|
+
onActivate={setActiveOption}
|
|
1276
|
+
/>,
|
|
1277
|
+
suggestion.container,
|
|
1278
|
+
)}
|
|
1279
|
+
|
|
1280
|
+
<BubbleMenu
|
|
1281
|
+
ref={bubbleRef}
|
|
1282
|
+
editor={editor}
|
|
1283
|
+
pluginKey={BUBBLE_MENU_KEY}
|
|
1284
|
+
updateDelay={0}
|
|
1285
|
+
shouldShow={({ editor: current }) => shouldShowBubble(current)}
|
|
1286
|
+
appendTo={() => shellRef.current ?? document.body}
|
|
1287
|
+
options={{
|
|
1288
|
+
placement: 'top',
|
|
1289
|
+
offset: readSpaceToken(shellRef.current, '--space-2', 8),
|
|
1290
|
+
flip: true,
|
|
1291
|
+
shift: true,
|
|
1292
|
+
}}
|
|
1293
|
+
className="ds-thread-bubble-menu"
|
|
1294
|
+
data-open={bubbleOpen}
|
|
1295
|
+
>
|
|
1296
|
+
{!bubbleOpen ? null : linkMode ? (
|
|
1297
|
+
<div
|
|
1298
|
+
role="group"
|
|
1299
|
+
aria-label="Link"
|
|
1300
|
+
className="flex items-center gap-[var(--space-1)]"
|
|
1301
|
+
style={{
|
|
1302
|
+
padding: 'var(--space-1)',
|
|
1303
|
+
background: 'rgb(var(--surface-card))',
|
|
1304
|
+
border: '1px solid rgb(var(--border))',
|
|
1305
|
+
borderRadius: 'var(--radius-sm)',
|
|
1306
|
+
boxShadow: 'var(--shadow-lg)',
|
|
1307
|
+
}}
|
|
1308
|
+
>
|
|
1309
|
+
<input
|
|
1310
|
+
ref={linkInputRef}
|
|
1311
|
+
type="url"
|
|
1312
|
+
aria-label="Link URL"
|
|
1313
|
+
aria-invalid={linkError}
|
|
1314
|
+
{...(linkError ? { 'aria-describedby': linkErrorId } : {})}
|
|
1315
|
+
placeholder="https://"
|
|
1316
|
+
value={linkDraft}
|
|
1317
|
+
onChange={(event) => {
|
|
1318
|
+
setLinkDraft(event.target.value)
|
|
1319
|
+
setLinkError(false)
|
|
1320
|
+
}}
|
|
1321
|
+
onKeyDown={onLinkFieldKeyDown}
|
|
1322
|
+
className="min-w-0 bg-transparent text-[12.5px] outline-none"
|
|
1323
|
+
style={{
|
|
1324
|
+
inlineSize: '22ch',
|
|
1325
|
+
blockSize: 'var(--ctrl-sm)',
|
|
1326
|
+
paddingInline: 'var(--space-2)',
|
|
1327
|
+
color: 'rgb(var(--foreground))',
|
|
1328
|
+
borderRadius: 'var(--radius-xs)',
|
|
1329
|
+
border: linkError
|
|
1330
|
+
? '1px solid rgb(var(--warning))'
|
|
1331
|
+
: '1px solid rgb(var(--border))',
|
|
1332
|
+
}}
|
|
1333
|
+
/>
|
|
1334
|
+
<Button
|
|
1335
|
+
variant="icon"
|
|
1336
|
+
size="sm"
|
|
1337
|
+
aria-label="Apply link"
|
|
1338
|
+
title="Apply link"
|
|
1339
|
+
onMouseDown={(event) => {
|
|
1340
|
+
event.preventDefault()
|
|
1341
|
+
}}
|
|
1342
|
+
onClick={applyLink}
|
|
1343
|
+
>
|
|
1344
|
+
<Check size={12} strokeWidth={2} aria-hidden="true" />
|
|
1345
|
+
</Button>
|
|
1346
|
+
{active.link ? (
|
|
1347
|
+
<Button
|
|
1348
|
+
variant="icon"
|
|
1349
|
+
size="sm"
|
|
1350
|
+
aria-label="Remove link"
|
|
1351
|
+
title="Remove link"
|
|
1352
|
+
onMouseDown={(event) => {
|
|
1353
|
+
event.preventDefault()
|
|
1354
|
+
}}
|
|
1355
|
+
onClick={removeLink}
|
|
1356
|
+
>
|
|
1357
|
+
<Unlink size={12} strokeWidth={1.75} aria-hidden="true" />
|
|
1358
|
+
</Button>
|
|
1359
|
+
) : null}
|
|
1360
|
+
<Button
|
|
1361
|
+
variant="icon"
|
|
1362
|
+
size="sm"
|
|
1363
|
+
aria-label="Close link field"
|
|
1364
|
+
title="Close"
|
|
1365
|
+
onMouseDown={(event) => {
|
|
1366
|
+
event.preventDefault()
|
|
1367
|
+
}}
|
|
1368
|
+
onClick={closeLinkField}
|
|
1369
|
+
>
|
|
1370
|
+
<X size={12} strokeWidth={1.75} aria-hidden="true" />
|
|
1371
|
+
</Button>
|
|
1372
|
+
{linkError ? (
|
|
1373
|
+
<span id={linkErrorId} role="alert" className="sr-only">
|
|
1374
|
+
Enter a web, email or phone link.
|
|
1375
|
+
</span>
|
|
1376
|
+
) : null}
|
|
1377
|
+
</div>
|
|
1378
|
+
) : (
|
|
1379
|
+
<div
|
|
1380
|
+
role="toolbar"
|
|
1381
|
+
aria-label="Format selection"
|
|
1382
|
+
aria-orientation="horizontal"
|
|
1383
|
+
onKeyDown={onToolbarKeyDown}
|
|
1384
|
+
>
|
|
1385
|
+
{BUBBLE_ACTIONS.map((action, index) => {
|
|
1386
|
+
const Icon = action.icon
|
|
1387
|
+
return (
|
|
1388
|
+
<Button
|
|
1389
|
+
key={action.id}
|
|
1390
|
+
variant="icon"
|
|
1391
|
+
size="sm"
|
|
1392
|
+
aria-label={action.label}
|
|
1393
|
+
aria-pressed={bubbleActive[action.id]}
|
|
1394
|
+
title={`${action.label} (${MOD_NAME}+${action.keycap})`}
|
|
1395
|
+
tabIndex={index === rovingIndex ? 0 : -1}
|
|
1396
|
+
onMouseDown={(event) => {
|
|
1397
|
+
// Keep the editor's selection: a click here must
|
|
1398
|
+
// format it, not collapse it.
|
|
1399
|
+
event.preventDefault()
|
|
1400
|
+
}}
|
|
1401
|
+
onClick={() => {
|
|
1402
|
+
runBubbleAction(action.id)
|
|
1403
|
+
}}
|
|
1404
|
+
>
|
|
1405
|
+
<Icon size={12} strokeWidth={1.75} aria-hidden="true" />
|
|
1406
|
+
</Button>
|
|
1407
|
+
)
|
|
1408
|
+
})}
|
|
1409
|
+
</div>
|
|
1410
|
+
)}
|
|
1411
|
+
</BubbleMenu>
|
|
1412
|
+
|
|
1413
|
+
<AttachmentTray
|
|
1414
|
+
attachments={attachments.attachments}
|
|
1415
|
+
onRemove={attachments.remove}
|
|
1416
|
+
onInsertAsText={insertPastedText}
|
|
1417
|
+
className="px-[var(--space-2)]"
|
|
1418
|
+
/>
|
|
1419
|
+
|
|
1420
|
+
{/* Switching composer implementations would unmount this hook and
|
|
1421
|
+
discard its local upload tickets. Remove the chips first; never
|
|
1422
|
+
turn a mode switch into a silent attachment loss. */}
|
|
1423
|
+
<ComposerFooter
|
|
1424
|
+
{...(mode === undefined ? {} : { mode })}
|
|
1425
|
+
{...(onModeChange === undefined ? {} : { onModeChange })}
|
|
1426
|
+
modeDisabled={controlsDisabled || attachments.attachments.length > 0}
|
|
1427
|
+
toolbar={
|
|
1428
|
+
<>
|
|
1429
|
+
{uploadAttachment === undefined ? null : (
|
|
1430
|
+
<AttachmentButton
|
|
1431
|
+
onFiles={attachments.addFiles}
|
|
1432
|
+
accept={attachmentAccept ?? ATTACHMENT_ACCEPT}
|
|
1433
|
+
disabled={controlsDisabled}
|
|
1434
|
+
/>
|
|
1435
|
+
)}
|
|
1436
|
+
{attachGif === undefined || searchGifs === undefined ? null : (
|
|
1437
|
+
<Popover open={gifPickerOpen} onOpenChange={setGifPickerOpen}>
|
|
1438
|
+
<Popover.Trigger asChild>
|
|
1439
|
+
<Button
|
|
1440
|
+
variant="icon"
|
|
1441
|
+
size="sm"
|
|
1442
|
+
aria-label="Choose a GIF"
|
|
1443
|
+
title="Choose a GIF"
|
|
1444
|
+
disabled={controlsDisabled}
|
|
1445
|
+
>
|
|
1446
|
+
<Images size={12} strokeWidth={1.75} aria-hidden="true" />
|
|
1447
|
+
</Button>
|
|
1448
|
+
</Popover.Trigger>
|
|
1449
|
+
<Popover.Content
|
|
1450
|
+
aria-label="Choose a GIF"
|
|
1451
|
+
align="start"
|
|
1452
|
+
className="p-[var(--space-3)]"
|
|
1453
|
+
style={{
|
|
1454
|
+
inlineSize: 'calc(var(--space-12) * 8)',
|
|
1455
|
+
maxInlineSize: 'calc(100vw - var(--space-6))',
|
|
1456
|
+
}}
|
|
1457
|
+
>
|
|
1458
|
+
<GifPicker
|
|
1459
|
+
search={searchGifs}
|
|
1460
|
+
onPick={(gif) => {
|
|
1461
|
+
attachments.addGif(gif)
|
|
1462
|
+
setGifPickerOpen(false)
|
|
1463
|
+
}}
|
|
1464
|
+
/>
|
|
1465
|
+
</Popover.Content>
|
|
1466
|
+
</Popover>
|
|
1467
|
+
)}
|
|
1468
|
+
{toolbar
|
|
1469
|
+
? BLOCK_ACTIONS.map((action) => {
|
|
1470
|
+
const Icon = action.icon
|
|
1471
|
+
return (
|
|
1472
|
+
<Button
|
|
1473
|
+
key={action.id}
|
|
1474
|
+
variant="icon"
|
|
1475
|
+
size="sm"
|
|
1476
|
+
aria-label={action.label}
|
|
1477
|
+
title={action.label}
|
|
1478
|
+
{...(action.id === 'divider' ? {} : { 'aria-pressed': blockActive[action.id] })}
|
|
1479
|
+
disabled={controlsDisabled}
|
|
1480
|
+
onMouseDown={(event) => {
|
|
1481
|
+
event.preventDefault()
|
|
1482
|
+
}}
|
|
1483
|
+
onClick={() => {
|
|
1484
|
+
runBlockAction(action.id)
|
|
1485
|
+
}}
|
|
1486
|
+
>
|
|
1487
|
+
<Icon size={12} strokeWidth={1.75} aria-hidden="true" />
|
|
1488
|
+
</Button>
|
|
1489
|
+
)
|
|
1490
|
+
})
|
|
1491
|
+
: null}
|
|
1492
|
+
</>
|
|
1493
|
+
}
|
|
1494
|
+
meta={
|
|
1495
|
+
<>
|
|
1496
|
+
{maxLength === undefined ? null : (
|
|
1497
|
+
<span
|
|
1498
|
+
className="tabular-nums text-right"
|
|
1499
|
+
// Reserved slot so the counter cannot push the buttons around.
|
|
1500
|
+
style={{
|
|
1501
|
+
minInlineSize: '7ch',
|
|
1502
|
+
fontSize: '11.5px',
|
|
1503
|
+
color:
|
|
1504
|
+
value.length > maxLength * 0.9
|
|
1505
|
+
? 'rgb(var(--warning-ink))'
|
|
1506
|
+
: 'rgb(var(--text-tertiary))',
|
|
1507
|
+
}}
|
|
1508
|
+
>
|
|
1509
|
+
{value.length}/{maxLength}
|
|
1510
|
+
</span>
|
|
1511
|
+
)}
|
|
1512
|
+
</>
|
|
1513
|
+
}
|
|
1514
|
+
actions={
|
|
1515
|
+
<>
|
|
1516
|
+
{onCancel === undefined ? null : (
|
|
1517
|
+
<Button variant="secondary" size="sm" onClick={onCancel} disabled={controlsDisabled}>
|
|
1518
|
+
{cancelLabel}
|
|
1519
|
+
</Button>
|
|
1520
|
+
)}
|
|
1521
|
+
<Button
|
|
1522
|
+
variant="primary"
|
|
1523
|
+
size="sm"
|
|
1524
|
+
onClick={() => {
|
|
1525
|
+
requestSubmit(editor)
|
|
1526
|
+
}}
|
|
1527
|
+
disabled={!canSend}
|
|
1528
|
+
aria-label={sending ? 'Sending…' : submitLabel}
|
|
1529
|
+
trailingIcon={
|
|
1530
|
+
<span aria-hidden="true" className="ds-composer-keycap">
|
|
1531
|
+
<Kbd size="sm" tone="on-accent">
|
|
1532
|
+
{SEND_KEYCAP}
|
|
1533
|
+
</Kbd>
|
|
1534
|
+
</span>
|
|
1535
|
+
}
|
|
1536
|
+
>
|
|
1537
|
+
{sending ? 'Sending…' : submitLabel}
|
|
1538
|
+
</Button>
|
|
1539
|
+
</>
|
|
1540
|
+
}
|
|
1541
|
+
/>
|
|
1542
|
+
</div>
|
|
1543
|
+
</AttachmentDropZone>
|
|
1544
|
+
</div>
|
|
1545
|
+
</div>
|
|
1546
|
+
)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function readHref(editor: Editor): string {
|
|
1550
|
+
const href = editor.getAttributes('link')['href']
|
|
1551
|
+
return typeof href === 'string' ? href : ''
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/* ── The two action tables ────────────────────────────────────────────────── */
|
|
1555
|
+
|
|
1556
|
+
type BubbleAction = 'bold' | 'italic' | 'underline' | 'strike' | 'code' | 'link'
|
|
1557
|
+
|
|
1558
|
+
interface BubbleActionSpec {
|
|
1559
|
+
readonly id: BubbleAction
|
|
1560
|
+
readonly label: string
|
|
1561
|
+
readonly icon: typeof Bold
|
|
1562
|
+
readonly keycap: string
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/** D7: six actions on a real selection, nothing else. */
|
|
1566
|
+
const BUBBLE_ACTIONS: readonly BubbleActionSpec[] = [
|
|
1567
|
+
{ id: 'bold', label: 'Bold', icon: Bold, keycap: 'B' },
|
|
1568
|
+
{ id: 'italic', label: 'Italic', icon: Italic, keycap: 'I' },
|
|
1569
|
+
{ id: 'underline', label: 'Underline', icon: UnderlineIcon, keycap: 'U' },
|
|
1570
|
+
{ id: 'strike', label: 'Strikethrough', icon: Strikethrough, keycap: 'Shift+X' },
|
|
1571
|
+
{ id: 'code', label: 'Code', icon: Code, keycap: 'E' },
|
|
1572
|
+
{ id: 'link', label: 'Link', icon: LinkIcon, keycap: 'K' },
|
|
1573
|
+
]
|
|
1574
|
+
|
|
1575
|
+
type BlockAction =
|
|
1576
|
+
| 'h1'
|
|
1577
|
+
| 'h2'
|
|
1578
|
+
| 'h3'
|
|
1579
|
+
| 'bulletList'
|
|
1580
|
+
| 'orderedList'
|
|
1581
|
+
| 'blockquote'
|
|
1582
|
+
| 'codeBlock'
|
|
1583
|
+
| 'divider'
|
|
1584
|
+
|
|
1585
|
+
interface BlockActionSpec {
|
|
1586
|
+
readonly id: BlockAction
|
|
1587
|
+
readonly label: string
|
|
1588
|
+
readonly icon: typeof Bold
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** The persistent row: block-level actions act on the block, not the selection. */
|
|
1592
|
+
const BLOCK_ACTIONS: readonly BlockActionSpec[] = [
|
|
1593
|
+
{ id: 'h1', label: 'Heading 1', icon: Heading1 },
|
|
1594
|
+
{ id: 'h2', label: 'Heading 2', icon: Heading2 },
|
|
1595
|
+
{ id: 'h3', label: 'Heading 3', icon: Heading3 },
|
|
1596
|
+
{ id: 'bulletList', label: 'Bulleted list', icon: List },
|
|
1597
|
+
{ id: 'orderedList', label: 'Numbered list', icon: ListOrdered },
|
|
1598
|
+
{ id: 'blockquote', label: 'Quote', icon: TextQuote },
|
|
1599
|
+
{ id: 'codeBlock', label: 'Code block', icon: SquareCode },
|
|
1600
|
+
{ id: 'divider', label: 'Divider', icon: Minus },
|
|
1601
|
+
]
|