@djangocfg/widget-editor 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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/package.json +121 -0
  4. package/src/blocks/BlockError.tsx +46 -0
  5. package/src/blocks/index.ts +34 -0
  6. package/src/blocks/schemas.ts +143 -0
  7. package/src/blocks/types.ts +76 -0
  8. package/src/chips/README.md +67 -0
  9. package/src/chips/UrlChip.tsx +105 -0
  10. package/src/chips/index.ts +20 -0
  11. package/src/index.ts +12 -0
  12. package/src/link-preview/LinkPreviewCard.tsx +178 -0
  13. package/src/link-preview/index.ts +12 -0
  14. package/src/link-preview/types.ts +44 -0
  15. package/src/markdown/MarkdownEditor.tsx +700 -0
  16. package/src/markdown/MentionList.tsx +106 -0
  17. package/src/markdown/README.md +269 -0
  18. package/src/markdown/chip/ChipNode.ts +526 -0
  19. package/src/markdown/chip/syncChips.ts +80 -0
  20. package/src/markdown/createMentionSuggestion.ts +131 -0
  21. package/src/markdown/filePath/FilePathChip.tsx +68 -0
  22. package/src/markdown/filePath/detect.ts +289 -0
  23. package/src/markdown/filePath/index.ts +15 -0
  24. package/src/markdown/index.ts +29 -0
  25. package/src/markdown/lazy.tsx +88 -0
  26. package/src/markdown/markdown-editor.stories.tsx +500 -0
  27. package/src/markdown/mentionPresets.ts +49 -0
  28. package/src/markdown/slash/SlashCommandNode.ts +162 -0
  29. package/src/markdown/slash/index.ts +4 -0
  30. package/src/markdown/slash/syncSlashNode.ts +97 -0
  31. package/src/markdown/slash/types.ts +14 -0
  32. package/src/markdown/styles.css +522 -0
  33. package/src/markdown/submitOnEnter.ts +83 -0
  34. package/src/markdown/types.ts +50 -0
  35. package/src/markdown/url/detect.ts +227 -0
  36. package/src/notion/BookmarkNode.ts +161 -0
  37. package/src/notion/BookmarkView.tsx +45 -0
  38. package/src/notion/CustomKeymap.ts +48 -0
  39. package/src/notion/LinkDialog.tsx +133 -0
  40. package/src/notion/MapEditLayer.tsx +199 -0
  41. package/src/notion/MapNode.ts +220 -0
  42. package/src/notion/MapView.tsx +103 -0
  43. package/src/notion/NotionEditor.tsx +309 -0
  44. package/src/notion/README.md +158 -0
  45. package/src/notion/SlashExtension.ts +32 -0
  46. package/src/notion/SlashList.tsx +136 -0
  47. package/src/notion/TaskItemView.tsx +41 -0
  48. package/src/notion/createSlashSuggestion.ts +121 -0
  49. package/src/notion/extensions.ts +120 -0
  50. package/src/notion/index.ts +1 -0
  51. package/src/notion/lazy.tsx +44 -0
  52. package/src/notion/notion-editor.stories.tsx +293 -0
  53. package/src/notion/slashItems.ts +178 -0
  54. package/src/notion/styles.css +503 -0
  55. package/src/notion/types.ts +37 -0
  56. package/src/tiptap/index.ts +10 -0
  57. package/src/tiptap/useNodeAttrs.ts +128 -0
@@ -0,0 +1,700 @@
1
+ 'use client';
2
+
3
+ import { useEditor, EditorContent, type Editor } from '@tiptap/react';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import Placeholder from '@tiptap/extension-placeholder';
6
+ import Mention from '@tiptap/extension-mention';
7
+ import { Markdown } from '@tiptap/markdown';
8
+ import type { AnyExtension } from '@tiptap/core';
9
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
10
+ import { useHotkey } from '@djangocfg/ui-core/hooks';
11
+ import {
12
+ Bold, Italic, Strikethrough, Heading1, Heading2, Heading3,
13
+ List, ListOrdered, Quote, Minus, Code, type LucideIcon,
14
+ } from 'lucide-react';
15
+ import { createMentionSuggestion } from './createMentionSuggestion';
16
+ import { mentionPresets } from './mentionPresets';
17
+ import { SlashCommandNode } from './slash/SlashCommandNode';
18
+ import { syncLeadingSlashNode } from './slash/syncSlashNode';
19
+ import type { SlashCommandInfo } from './slash/types';
20
+ import { SubmitOnEnter } from './submitOnEnter';
21
+ import { ChipNode } from './chip/ChipNode';
22
+ import { syncChips } from './chip/syncChips';
23
+ import type { MentionAttrs, MentionConfig } from './types';
24
+ import './styles.css';
25
+
26
+ // ── Helpers ──
27
+
28
+ /**
29
+ * Whitelist of URL schemes the autolinker is allowed to turn into a
30
+ * link. We deliberately keep this to real, clickable transports.
31
+ */
32
+ const AUTO_LINK_PROTOCOL = /^(?:https?|ftp|mailto|file):/i;
33
+
34
+ /**
35
+ * Decide whether an autolink candidate should actually become a link.
36
+ *
37
+ * StarterKit's bundled `Link` extension ships with a permissive default
38
+ * `shouldAutoLink` that links any token whose "hostname" contains a dot
39
+ * with a plausible TLD. That over-eagerly turns *bare file paths and
40
+ * dotted filenames* into `http://…` links — e.g. typing a local path
41
+ * like `…/positioning/manifest.md` linkifies `manifest.md` as the
42
+ * Moldova (`.md`) domain, and pasted prose around a dotted token can be
43
+ * swallowed into one anchor. None of that is wanted in a chat composer.
44
+ *
45
+ * We instead require an explicit, known protocol (`http`, `https`,
46
+ * `ftp`, `mailto`, `file`) or a `www.`-prefixed host. linkifyjs has
47
+ * already split the candidate on whitespace, so the match is inherently
48
+ * bounded to a single token — a path like `/a/b/file.md` is never a
49
+ * `*://` URL and never a `www.` host, so it stays plain text, and any
50
+ * Unicode/Cyrillic words after a real URL remain plain text too.
51
+ */
52
+ function shouldAutoLinkUrl(url: string): boolean {
53
+ if (AUTO_LINK_PROTOCOL.test(url)) return true;
54
+ // Bare `www.host…` (no scheme) — linkify prepends the default
55
+ // protocol; allow it since it's an unambiguous web address, not a
56
+ // local path.
57
+ if (/^www\.[^\s/$.?#].[^\s]*$/i.test(url)) return true;
58
+ return false;
59
+ }
60
+
61
+ interface MarkdownManager {
62
+ serialize: (json: Record<string, unknown>) => string;
63
+ }
64
+
65
+ /**
66
+ * Serialize the editor document to a markdown string.
67
+ *
68
+ * `@tiptap/markdown` v3 augments the `Editor` with a `getMarkdown()`
69
+ * method; we use it when present and fall back to the storage manager
70
+ * (and ultimately `getText()`) so the editor still produces *something*
71
+ * if the extension shape ever drifts.
72
+ */
73
+ function getMarkdown(editor: Editor): string {
74
+ const withMd = editor as Editor & { getMarkdown?: () => string };
75
+ if (typeof withMd.getMarkdown === 'function') return withMd.getMarkdown();
76
+ const storage = editor.storage.markdown as { manager?: MarkdownManager } | undefined;
77
+ if (!storage?.manager) return editor.getText();
78
+ return storage.manager.serialize(editor.getJSON());
79
+ }
80
+
81
+ function extractMentionIds(editor: Editor): string[] {
82
+ const ids: string[] = [];
83
+ editor.state.doc.descendants((node) => {
84
+ if (node.type.name === 'mention' && node.attrs.id) {
85
+ ids.push(node.attrs.id as string);
86
+ }
87
+ });
88
+ return [...new Set(ids)];
89
+ }
90
+
91
+ // ── Types ──
92
+
93
+ export interface MarkdownEditorProps {
94
+ value: string;
95
+ onChange: (value: string) => void;
96
+ placeholder?: string;
97
+ minHeight?: number;
98
+ className?: string;
99
+ disabled?: boolean;
100
+ showToolbar?: boolean;
101
+ /**
102
+ * Drop the editor's own border / background / focus ring. Use when
103
+ * the editor is embedded inside a host surface that already draws
104
+ * the frame (e.g. the chat composer's textarea slot) — avoids a
105
+ * double border.
106
+ */
107
+ unstyled?: boolean;
108
+ /**
109
+ * `@`-mention autocomplete config.
110
+ *
111
+ * IMPORTANT: Tiptap's `useEditor` initialises the editor exactly once.
112
+ * The `Mention` extension is only registered when `mentions` is truthy
113
+ * on the FIRST render — handing in a real config later (e.g. after an
114
+ * async items fetch) silently does nothing, and typing `@` will not
115
+ * open the popover.
116
+ *
117
+ * If you want mentions even with async-loaded items, pass
118
+ * `{ items: [] }` from the very first render and update the array
119
+ * when data arrives. Either keep the `MentionConfig` object identity
120
+ * stable across renders and mutate `items` in place (the suggestion
121
+ * plugin captures the config by closure and reads `items` on each
122
+ * query), or accept that swapping the whole object reference is a
123
+ * no-op for the live editor.
124
+ */
125
+ mentions?: MentionConfig;
126
+ /**
127
+ * Slash-command verb list. When set, the editor registers a
128
+ * `SlashCommandNode` extension and replaces a leading `/verb` (whose
129
+ * verb is in this list) in the document with an atomic chip — the
130
+ * TipTap analogue of the plain `<SlashHighlightTextarea>` mirror.
131
+ *
132
+ * Like `mentions`, this is captured by `useEditor` exactly once on
133
+ * first render. To register slash commands at all, pass `[]` from
134
+ * the very first render and mutate / swap as needed. The conversion
135
+ * effect re-reads the list on every value sync (it lives in a ref),
136
+ * so verbs added later are recognised next time the buffer is set.
137
+ *
138
+ * Lives in `@djangocfg/widget-editor/markdown` (not chat) so the
139
+ * editor stays a leaf dependency. Structurally compatible with the
140
+ * chat package's `SlashCommand[]` — pass either directly.
141
+ */
142
+ slashCommands?: readonly SlashCommandInfo[];
143
+ /**
144
+ * Render absolute LOCAL file paths typed/pasted into the editor as
145
+ * compact, atomic file chips (icon + VSCode-style middle-ellipsis label,
146
+ * full path on hover) — Cursor/Finder style. Backed by an inline atom
147
+ * NODE (`editorChip`, `kind: 'path'`), NOT a decoration: the chip owns
148
+ * its rendering so a long path collapses to ONE line (`…/dev/Map/index.ts`)
149
+ * instead of wrapping. The node serialises back to the RAW path, so caret
150
+ * (whole-chip delete), copy, and the submitted markdown all keep the
151
+ * literal `…/manifest.md`. Detection is conservative (absolute Unix /
152
+ * macOS / `~` / Windows-drive / UNC / `file://` only — never bare
153
+ * relative tokens or web URLs).
154
+ *
155
+ * Defaults to ON (the chat composer wants it). Pass `false` for editors
156
+ * that should leave paths as plain text. Like `mentions`/`slashCommands`
157
+ * this is captured by `useEditor` on first render — flipping it after
158
+ * mount won't add/remove the extension on the live instance.
159
+ */
160
+ filePathChips?: boolean;
161
+ /**
162
+ * Render web URLs typed/pasted into the editor as compact, atomic URL
163
+ * chips (favicon — degrading to a globe glyph — + domain and a
164
+ * middle-ellipsis of the path, e.g. `github.com/…/README.md`, full URL on
165
+ * hover). Clickable (opens in a new tab, `rel=noopener`). Same atom NODE
166
+ * as the file-path chip (`editorChip`, `kind: 'url'`); it REPLACES the
167
+ * plain blue underlined link look and serialises back to the RAW URL so
168
+ * copy / submit / markdown keep the literal address. Uses the same
169
+ * autolink whitelist as the editor (`http`/`https`/`ftp`/`mailto`/`file`
170
+ * + bare `www.`).
171
+ *
172
+ * Defaults to ON. Captured on first render like `filePathChips`.
173
+ */
174
+ urlChips?: boolean;
175
+ /** Called when mentioned IDs change */
176
+ onMentionIdsChange?: (ids: string[]) => void;
177
+ /**
178
+ * Called when the user presses Enter (without Shift, no IME
179
+ * composition, no mention popover open). When set, Enter submits
180
+ * and Shift+Enter inserts a newline — ChatGPT / Telegram chat
181
+ * behaviour. When omitted, Enter behaves as Tiptap default
182
+ * (HardBreak).
183
+ *
184
+ * Implementation lives in `submitOnEnter.ts` — it's a Tiptap
185
+ * keymap extension, NOT a React wrapper handler. Wrapper-level
186
+ * onKeyDown fires AFTER ProseMirror's keymap commits HardBreak in
187
+ * the same tick; routing through the extension lets us intercept
188
+ * before HardBreak runs.
189
+ *
190
+ * Return value (optional): truthy / undefined = consume the key
191
+ * (default). Return `false` from onSubmit to let Tiptap fall
192
+ * through to HardBreak — useful for guards like "don't submit an
193
+ * empty draft".
194
+ */
195
+ onSubmit?: () => boolean | void;
196
+ /**
197
+ * Focus the editor on mount. Pair with `key={file}` upstream when the
198
+ * host wants a fresh focus per file change (inspector / editor tab).
199
+ */
200
+ autoFocus?: boolean;
201
+ /**
202
+ * Called when the user presses Cmd/Ctrl+S inside the editor. Receives
203
+ * the current markdown. The browser's "save page" default is suppressed
204
+ * only when this handler is supplied — otherwise Cmd+S falls through.
205
+ */
206
+ onSave?: (markdown: string) => void;
207
+ }
208
+
209
+ /**
210
+ * Imperative handle exposed via `ref`. Shaped for
211
+ * `@djangocfg/widget-forms/composer-registry` so consumers can forward it
212
+ * straight into `useRegisterComposer({ focus, moveCursorToEnd })` — that's what
213
+ * makes voice dictation (`VoiceComposerSlot`) push live text into a TipTap
214
+ * composer.
215
+ */
216
+ export interface MarkdownEditorHandle {
217
+ /** Move keyboard focus into the editor. */
218
+ focus: () => void;
219
+ /** Place the caret at the end of the document (and focus). */
220
+ moveCursorToEnd: () => void;
221
+ /** Escape hatch — the underlying TipTap `Editor` instance. */
222
+ getEditor: () => Editor | null;
223
+ }
224
+
225
+ // ── Component ──
226
+
227
+ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor(
228
+ {
229
+ value,
230
+ onChange,
231
+ placeholder = 'Write markdown...',
232
+ minHeight = 120,
233
+ className = '',
234
+ disabled = false,
235
+ showToolbar = true,
236
+ unstyled = false,
237
+ mentions,
238
+ slashCommands,
239
+ filePathChips = true,
240
+ urlChips = true,
241
+ onMentionIdsChange,
242
+ onSubmit,
243
+ autoFocus = false,
244
+ onSave,
245
+ },
246
+ ref,
247
+ ) {
248
+ // Keep the latest onSubmit in a ref so the Tiptap extension's
249
+ // keymap closure always calls the freshest handler — Tiptap's
250
+ // useEditor initialises extensions ONCE on first render. Without
251
+ // the ref the extension would call a stale onSubmit (e.g. one
252
+ // that references an outdated `value`).
253
+ const onSubmitRef = useRef(onSubmit);
254
+ onSubmitRef.current = onSubmit;
255
+ const isExternalUpdate = useRef(false);
256
+
257
+ // Slash list lives in a ref so verbs added at runtime (e.g. context-
258
+ // aware commands registered after a first reply) are still recognised
259
+ // by the post-`setContent` conversion pass. The editor extension itself
260
+ // is registered once on mount — that is intentional and matches how
261
+ // `mentions` works (Tiptap's `useEditor` captures extensions once).
262
+ const slashCommandsRef = useRef<readonly SlashCommandInfo[]>(
263
+ slashCommands ?? [],
264
+ );
265
+ slashCommandsRef.current = slashCommands ?? [];
266
+ const slashEnabledOnMountRef = useRef<boolean>(slashCommands !== undefined);
267
+ // File-path / URL chips: captured once on mount (the ChipNode extension is
268
+ // installed on first render, like mentions/slash). Both default ON. Kept
269
+ // in refs so the post-`setContent` `syncChips` pass reads the right flags.
270
+ const filePathChipsOnMountRef = useRef<boolean>(filePathChips);
271
+ const urlChipsOnMountRef = useRef<boolean>(urlChips);
272
+ const slashWarnedRef = useRef(false);
273
+ if (
274
+ process.env.NODE_ENV !== 'production' &&
275
+ !slashEnabledOnMountRef.current &&
276
+ slashCommands !== undefined &&
277
+ !slashWarnedRef.current
278
+ ) {
279
+ slashWarnedRef.current = true;
280
+ // eslint-disable-next-line no-console
281
+ console.warn(
282
+ '[MarkdownEditor] `slashCommands` flipped from undefined to a list ' +
283
+ 'after mount. Tiptap only installs the SlashCommandNode extension ' +
284
+ 'on first render — the in-editor chip will NOT appear for this ' +
285
+ 'editor instance. Pass `[]` from the very first render and mutate ' +
286
+ 'the array in place (or swap references) so the conversion effect ' +
287
+ 'sees the new verbs.',
288
+ );
289
+ }
290
+
291
+ // ── Dev-mode trap detector ──
292
+ // Tiptap initialises the editor once with the extensions array from
293
+ // first render. If `mentions` is undefined on mount and becomes
294
+ // truthy later, the Mention extension is never installed and the
295
+ // user-visible @-trigger silently does nothing. Catch this early so
296
+ // future consumers don't spend hours debugging why @ does nothing.
297
+ const initialMentionsDefinedRef = useRef<boolean>(mentions !== undefined);
298
+ const warnedRef = useRef(false);
299
+ if (
300
+ process.env.NODE_ENV !== 'production' &&
301
+ !initialMentionsDefinedRef.current &&
302
+ mentions !== undefined &&
303
+ !warnedRef.current
304
+ ) {
305
+ warnedRef.current = true;
306
+ // eslint-disable-next-line no-console
307
+ console.warn(
308
+ '[MarkdownEditor] `mentions` flipped from undefined to a config ' +
309
+ 'after mount. Tiptap only installs the Mention extension on first ' +
310
+ 'render — the @-popover will NOT work for this editor instance. ' +
311
+ 'Pass `{ items: [] }` from the very first render and mutate `.items` ' +
312
+ 'in place instead.',
313
+ );
314
+ }
315
+
316
+ const extensions = useMemo(() => {
317
+ const exts: AnyExtension[] = [
318
+ StarterKit.configure({
319
+ heading: { levels: [1, 2, 3] },
320
+ // Constrain the bundled Link extension's autolinker. The default
321
+ // links bare dotted tokens (e.g. a file path's `manifest.md`)
322
+ // and can over-capture pasted prose; we only auto-link real
323
+ // URLs (explicit scheme or `www.`). `defaultProtocol: 'https'`
324
+ // is the modern default for the rare schemeless `www.` case.
325
+ //
326
+ // When `urlChips` is ON the ChipNode OWNS URL rendering (a chip,
327
+ // not a blue underline), so we turn OFF the link autolinker to
328
+ // avoid the two fighting over the same token. Markdown `[text](url)`
329
+ // links typed deliberately still render via the link mark — only
330
+ // the implicit autolink of a bare URL is suppressed.
331
+ link: {
332
+ defaultProtocol: 'https',
333
+ autolink: !urlChipsOnMountRef.current,
334
+ shouldAutoLink: shouldAutoLinkUrl,
335
+ },
336
+ }),
337
+ Placeholder.configure({ placeholder }),
338
+ Markdown,
339
+ // SubmitOnEnter — when the consumer wired an onSubmit, intercept
340
+ // Enter at the keymap level (before StarterKit's HardBreak).
341
+ // The extension calls through `onSubmitRef.current` so handler
342
+ // identity changes don't require an editor rebuild. See
343
+ // submitOnEnter.ts for the keymap-vs-wrapper-handler rationale.
344
+ SubmitOnEnter.configure({
345
+ onSubmit: () => {
346
+ const h = onSubmitRef.current;
347
+ if (!h) return false; // no handler → let Tiptap insert HardBreak
348
+ return h();
349
+ },
350
+ }),
351
+ ];
352
+
353
+ // SlashCommandNode — TipTap atom that paints the `/verb` chip.
354
+ // Registered whenever `slashCommands` was defined on first render
355
+ // (the empty array still counts as "I want this feature"). The
356
+ // node has no suggestion plugin of its own — the chat composer's
357
+ // floating menu drives selection and the editor only needs to
358
+ // know how to render + serialize the atom.
359
+ if (slashEnabledOnMountRef.current) {
360
+ exts.push(SlashCommandNode);
361
+ }
362
+
363
+ // ChipNode — a single inline ATOM node that renders BOTH file-path
364
+ // chips (`kind: 'path'`) and URL chips (`kind: 'url'`) with a
365
+ // VSCode-style middle-ellipsis label, and serialises back to the raw
366
+ // path / URL. Registered whenever either chip feature is on (the node's
367
+ // input/paste rules are gated per-kind via `enablePaths`/`enableUrls`).
368
+ // Replaces the old FilePathChipExtension decoration — a node owns its
369
+ // rendering, so a long path collapses to ONE line instead of wrapping.
370
+ if (filePathChipsOnMountRef.current || urlChipsOnMountRef.current) {
371
+ exts.push(
372
+ ChipNode.configure({
373
+ enablePaths: filePathChipsOnMountRef.current,
374
+ enableUrls: urlChipsOnMountRef.current,
375
+ }),
376
+ );
377
+ }
378
+
379
+ if (mentions) {
380
+ // ── Why .extend() with renderMarkdown ──
381
+ //
382
+ // Tiptap's `Mention` extension ships a default markdown serializer
383
+ // (via `createInlineMarkdownSpec`, see @tiptap/extension-mention)
384
+ // that emits a shortcode like `[@ id="..." label="..."]`. That's
385
+ // round-trippable but useless for most consumers: a chat composer
386
+ // feeding an LLM wants `@<label>`, a deep-linking app wants a
387
+ // markdown link, etc.
388
+ //
389
+ // `renderText` only affects `editor.getText()` and the rendered
390
+ // node — it does NOT influence `@tiptap/markdown`'s serializer.
391
+ // The serializer reads `renderMarkdown` off the extension config
392
+ // (see MarkdownManager.registerExtension → getExtensionField).
393
+ // Overriding it via `.extend({ renderMarkdown })` replaces the
394
+ // shortcode output with whatever the consumer supplied (or the
395
+ // `plainAt` default — see ./mentionPresets.ts).
396
+ //
397
+ // Renderer-choice capture: useEditor only initialises once, so the
398
+ // chosen renderer is captured by closure on first render. This is
399
+ // intentional — swapping renderers per render would mean tearing
400
+ // the editor down anyway, which we don't do for any other prop.
401
+ //
402
+ // Round-trip note: we intentionally do NOT also override
403
+ // `parseMarkdown` / `markdownTokenizer`. Once a mention is
404
+ // serialized, parsing it back would need the original mention
405
+ // items list to look up the id, which we don't have at parse time.
406
+ // Mentions are write-only by design here — `setContent(value)`
407
+ // after submit gets back a plain string, which is fine for the
408
+ // chat use case.
409
+ const renderMarkdown = mentions.renderMarkdown ?? mentionPresets.plainAt;
410
+ exts.push(
411
+ Mention.extend({
412
+ renderMarkdown(node) {
413
+ const raw = node.attrs as { label?: string | null; id?: string | null };
414
+ const attrs: MentionAttrs = {
415
+ id: raw?.id ?? '',
416
+ label: raw?.label ?? '',
417
+ };
418
+ // Defensive: if both are empty (shouldn't happen for a real
419
+ // mention node, but `setContent` of malformed data could),
420
+ // emit nothing rather than a stray "@" or invalid link.
421
+ if (!attrs.id && !attrs.label) return '';
422
+ return renderMarkdown(attrs);
423
+ },
424
+ }).configure({
425
+ HTMLAttributes: { class: 'markdown-mention' },
426
+ suggestion: createMentionSuggestion(mentions),
427
+ renderText: ({ node }) => `@${node.attrs.label}`,
428
+ }),
429
+ );
430
+ }
431
+
432
+ return exts;
433
+ }, [placeholder, mentions]);
434
+
435
+ const editor = useEditor({
436
+ immediatelyRender: false,
437
+ editable: !disabled,
438
+ extensions,
439
+ content: value,
440
+ // `value` is a markdown string — without this Tiptap treats the
441
+ // content as JSON and renders `# Hello` literally instead of an <h1>.
442
+ contentType: 'markdown',
443
+ onUpdate: ({ editor }) => {
444
+ if (isExternalUpdate.current) return;
445
+ // Promote a freshly-typed `/verb ` text run into a chip so users
446
+ // who type the command without going through the menu still see
447
+ // the highlight — matches the plain mirror's behaviour where
448
+ // `extractSlashToken` works for any leading slash. The helper is
449
+ // idempotent and a no-op when no leading text-slash exists.
450
+ if (slashEnabledOnMountRef.current) {
451
+ const replaced = syncLeadingSlashNode(editor, slashCommandsRef.current);
452
+ if (replaced) {
453
+ // The chain() above triggers an extra onUpdate. The string
454
+ // serialisation is unchanged (atom flattens to its token via
455
+ // `renderText`), so we still want the outer onChange to fire
456
+ // with the canonical value. Falling through is fine.
457
+ }
458
+ }
459
+ onChange(getMarkdown(editor));
460
+
461
+ if (onMentionIdsChange) {
462
+ onMentionIdsChange(extractMentionIds(editor));
463
+ }
464
+ },
465
+ editorProps: {
466
+ attributes: {
467
+ // 15px matches the `.ProseMirror` typographic baseline in
468
+ // styles.css — a comfortable chat-composer body size. Do NOT
469
+ // shrink to `text-sm` here: that 14px override made the composer
470
+ // input read too small against the rest of the chat surface.
471
+ class: 'markdown-editor-content focus:outline-none text-[15px]',
472
+ style: `min-height: ${minHeight}px`,
473
+ },
474
+ handleDOMEvents: {
475
+ // FILE drops must NOT be consumed by ProseMirror. PM's built-in
476
+ // `editHandlers.drop` calls `event.preventDefault()` on the editor
477
+ // surface (prosemirror-view: handleDrop), which swallows a file
478
+ // drop before it can bubble to the host wrapper's `onDrop` (the
479
+ // chat composer's attach pipeline).
480
+ //
481
+ // PM's dispatch (prosemirror-view `dispatchEvent`) runs the
482
+ // built-in handler ONLY when the custom handler returns a falsy
483
+ // value AND did not `preventDefault`. So to let a file drop reach
484
+ // the composer we must:
485
+ // • return `true` → tells PM "handled", it SKIPS its built-in
486
+ // drop, so it never preventDefaults; the
487
+ // native event keeps bubbling to React onDrop.
488
+ // • NOT preventDefault here (that would also block the bubble
489
+ // target from acting, and PM treats defaultPrevented as
490
+ // "handled" too — same skip, but we keep it explicit).
491
+ // For text / node drops (no `dataTransfer.files`) return `false`
492
+ // so PM's normal in-editor drop handling runs unchanged.
493
+ drop: (_view, event) => {
494
+ const dt = (event as DragEvent).dataTransfer;
495
+ if (dt && dt.files && dt.files.length > 0) {
496
+ // Hands-off: skip PM's drop, let it bubble to composer.onDrop.
497
+ return true;
498
+ }
499
+ return false;
500
+ },
501
+ },
502
+ },
503
+ });
504
+
505
+ // Sync external `value` → editor. `setContent` is given `contentType:
506
+ // 'markdown'` so the string is parsed (not treated as JSON), and
507
+ // `emitUpdate: false` so it does NOT re-fire `onUpdate` (which would
508
+ // loop back into `onChange`). `isExternalUpdate` stays as a guard.
509
+ useEffect(() => {
510
+ if (!editor) return;
511
+ const current = getMarkdown(editor);
512
+ if (current !== value) {
513
+ // `setContent` replaces the ProseMirror document and therefore creates a
514
+ // fresh selection. In a controlled chat editor an unrelated parent
515
+ // render can arrive while the user is typing; without preserving this
516
+ // selection Chromium may release focus to <body> or place the caret at
517
+ // document start. External content still wins, but it must never erase
518
+ // the user's active editing position.
519
+ const hadFocus = editor.isFocused;
520
+ const previousSelection = editor.state.selection;
521
+ isExternalUpdate.current = true;
522
+ editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
523
+ // After the new content is written, convert a leading `/verb` text
524
+ // run into a `SlashCommandNode` atom so the chip appears. The
525
+ // helper is a no-op when (a) slash commands are not enabled,
526
+ // (b) the buffer doesn't start with a known verb, or (c) the
527
+ // document already begins with the atom. `emitUpdate: false` on
528
+ // setContent + `isExternalUpdate.current` still being `true`
529
+ // suppresses the onUpdate -> onChange feedback loop the chain()
530
+ // call would otherwise trigger.
531
+ if (slashEnabledOnMountRef.current) {
532
+ syncLeadingSlashNode(editor, slashCommandsRef.current);
533
+ }
534
+ // Convert any bare path / URL text in the freshly-parsed markdown into
535
+ // chip atoms. Input/paste rules handle live typing; this covers
536
+ // content that arrived via the controlled `value` (parsed straight to
537
+ // plain text). The chips serialise back to the raw string, so the next
538
+ // `getMarkdown(editor)` still equals `value` — no feedback loop.
539
+ if (filePathChipsOnMountRef.current || urlChipsOnMountRef.current) {
540
+ syncChips(editor, {
541
+ paths: filePathChipsOnMountRef.current,
542
+ urls: urlChipsOnMountRef.current,
543
+ });
544
+ }
545
+ isExternalUpdate.current = false;
546
+
547
+ if (hadFocus) {
548
+ // SetContent / chip normalization can shorten the document. Clamp the
549
+ // old range to the new valid bounds, then restore it after focusing so
550
+ // both a collapsed caret and a text selection remain where the user
551
+ // left them. We intentionally do not focus an editor that was not
552
+ // active — a background update must not steal focus from another UI.
553
+ const maxPosition = editor.state.doc.content.size;
554
+ const from = Math.max(1, Math.min(previousSelection.from, maxPosition));
555
+ const to = Math.max(1, Math.min(previousSelection.to, maxPosition));
556
+ editor.chain().focus().setTextSelection({ from, to }).run();
557
+ }
558
+ }
559
+ }, [value, editor]);
560
+
561
+ // Keep editability in sync with the `disabled` prop. `useEditor` only
562
+ // reads `editable` on init, so toggling `disabled` later is otherwise
563
+ // a no-op.
564
+ useEffect(() => {
565
+ editor?.setEditable(!disabled);
566
+ }, [editor, disabled]);
567
+
568
+ // Declarative autoFocus — runs once the editor instance exists. Hosts
569
+ // that want per-file focus reset should pair this with `key={path}`.
570
+ useEffect(() => {
571
+ if (!autoFocus || !editor) return;
572
+ editor.commands.focus('end');
573
+ }, [autoFocus, editor]);
574
+
575
+ // Cmd/Ctrl+S → save. Uses the shared `useHotkey` (inInput=true by
576
+ // default for modifier combos). Guarded to fire only when focus is
577
+ // inside this editor's ProseMirror DOM, so multiple sibling editors
578
+ // don't all fire on one chord.
579
+ const onSaveRef = useRef(onSave);
580
+ onSaveRef.current = onSave;
581
+ useHotkey(
582
+ 'mod+s',
583
+ () => {
584
+ const h = onSaveRef.current;
585
+ if (!h || !editor) return;
586
+ const dom = editor.view.dom;
587
+ const active = document.activeElement;
588
+ if (!active || !dom.contains(active)) return;
589
+ h(getMarkdown(editor));
590
+ },
591
+ { enabled: !!editor && !!onSave },
592
+ );
593
+
594
+ // Imperative API for hosts that drive the editor without owning a
595
+ // TipTap ref directly — chat composer registration, voice slot,
596
+ // focus-on-stream-end.
597
+ useImperativeHandle(
598
+ ref,
599
+ (): MarkdownEditorHandle => ({
600
+ focus: () => {
601
+ editor?.commands.focus();
602
+ },
603
+ moveCursorToEnd: () => {
604
+ editor?.commands.focus('end');
605
+ },
606
+ getEditor: () => editor ?? null,
607
+ }),
608
+ [editor],
609
+ );
610
+
611
+ // `unstyled` drops the editor's own frame (border / bg / focus ring)
612
+ // so it can sit inside a host surface that already draws one.
613
+ const chromeClass = unstyled
614
+ ? 'markdown-editor--unstyled'
615
+ : 'rounded-md border border-input bg-background';
616
+ const wrapperClass = `markdown-editor ${chromeClass} ${disabled ? 'opacity-60' : ''} ${className}`.trim();
617
+
618
+ // Inner content padding.
619
+ //
620
+ // Standalone editor: comfy `px-3 py-2` frame.
621
+ //
622
+ // Unstyled (embedded in the chat composer surface): the host owns the
623
+ // OUTER frame, but the editor text still needs a small, EVEN inset so
624
+ // it doesn't sit flush in the corner. We give it a balanced horizontal +
625
+ // top inset and a DELIBERATELY tighter bottom (`pb-px`): a symmetric
626
+ // `py-2` would stack a second 8px gap under the single caret line and
627
+ // read as a phantom empty line (the bug this replaces). `pt-1.5 pb-px`
628
+ // breathes at the top without re-introducing the bottom gap; `px-2`
629
+ // keeps left == right so the caret isn't jammed against the edge.
630
+ const contentPad = unstyled ? 'px-2 pt-1.5 pb-px' : 'px-3 py-2';
631
+
632
+ return (
633
+ <div className={wrapperClass}>
634
+ {showToolbar && editor && <MarkdownToolbar editor={editor} />}
635
+ <div className={contentPad}>
636
+ <EditorContent editor={editor} />
637
+ </div>
638
+ </div>
639
+ );
640
+ });
641
+
642
+ // ── Toolbar ──
643
+
644
+ interface ToolbarItem {
645
+ icon: LucideIcon;
646
+ action: () => void;
647
+ active: boolean;
648
+ title: string;
649
+ }
650
+
651
+ function MarkdownToolbar({ editor }: { editor: Editor }) {
652
+ const items = useMemo<(ToolbarItem | null)[]>(() => [
653
+ { icon: Bold, title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold') },
654
+ { icon: Italic, title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic') },
655
+ { icon: Strikethrough, title: 'Strike', action: () => editor.chain().focus().toggleStrike().run(), active: editor.isActive('strike') },
656
+ { icon: Code, title: 'Code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code') },
657
+ null,
658
+ { icon: Heading1, title: 'H1', action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), active: editor.isActive('heading', { level: 1 }) },
659
+ { icon: Heading2, title: 'H2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }) },
660
+ { icon: Heading3, title: 'H3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }) },
661
+ null,
662
+ { icon: List, title: 'Bullet list', action: () => editor.chain().focus().toggleBulletList().run(), active: editor.isActive('bulletList') },
663
+ { icon: ListOrdered, title: 'Ordered list', action: () => editor.chain().focus().toggleOrderedList().run(), active: editor.isActive('orderedList') },
664
+ { icon: Quote, title: 'Quote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote') },
665
+ { icon: Minus, title: 'Divider', action: () => editor.chain().focus().setHorizontalRule().run(), active: false },
666
+ ], [editor]);
667
+
668
+ return (
669
+ <div
670
+ className="flex items-center gap-0.5 px-2 py-1.5 border-b border-border"
671
+ role="toolbar"
672
+ aria-label="Text formatting"
673
+ >
674
+ {items.map((item, i) => {
675
+ if (!item) {
676
+ return <div key={i} className="w-px h-4 bg-border mx-1" aria-hidden="true" />;
677
+ }
678
+ const Icon = item.icon;
679
+ const btnClass = `markdown-toolbar-btn ${item.active ? 'active' : ''}`;
680
+ return (
681
+ <button
682
+ key={i}
683
+ type="button"
684
+ // Prevent the button from stealing focus from the editor on
685
+ // mousedown — keeps the selection intact so the formatting
686
+ // command applies to the user's current selection.
687
+ onMouseDown={(e) => e.preventDefault()}
688
+ onClick={item.action}
689
+ title={item.title}
690
+ aria-label={item.title}
691
+ aria-pressed={item.active}
692
+ className={btnClass}
693
+ >
694
+ <Icon style={{ width: 14, height: 14 }} aria-hidden="true" />
695
+ </button>
696
+ );
697
+ })}
698
+ </div>
699
+ );
700
+ }