@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +121 -0
- package/src/blocks/BlockError.tsx +46 -0
- package/src/blocks/index.ts +34 -0
- package/src/blocks/schemas.ts +143 -0
- package/src/blocks/types.ts +76 -0
- package/src/chips/README.md +67 -0
- package/src/chips/UrlChip.tsx +105 -0
- package/src/chips/index.ts +20 -0
- package/src/index.ts +12 -0
- package/src/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/link-preview/index.ts +12 -0
- package/src/link-preview/types.ts +44 -0
- package/src/markdown/MarkdownEditor.tsx +700 -0
- package/src/markdown/MentionList.tsx +106 -0
- package/src/markdown/README.md +269 -0
- package/src/markdown/chip/ChipNode.ts +526 -0
- package/src/markdown/chip/syncChips.ts +80 -0
- package/src/markdown/createMentionSuggestion.ts +131 -0
- package/src/markdown/filePath/FilePathChip.tsx +68 -0
- package/src/markdown/filePath/detect.ts +289 -0
- package/src/markdown/filePath/index.ts +15 -0
- package/src/markdown/index.ts +29 -0
- package/src/markdown/lazy.tsx +88 -0
- package/src/markdown/markdown-editor.stories.tsx +500 -0
- package/src/markdown/mentionPresets.ts +49 -0
- package/src/markdown/slash/SlashCommandNode.ts +162 -0
- package/src/markdown/slash/index.ts +4 -0
- package/src/markdown/slash/syncSlashNode.ts +97 -0
- package/src/markdown/slash/types.ts +14 -0
- package/src/markdown/styles.css +522 -0
- package/src/markdown/submitOnEnter.ts +83 -0
- package/src/markdown/types.ts +50 -0
- package/src/markdown/url/detect.ts +227 -0
- package/src/notion/BookmarkNode.ts +161 -0
- package/src/notion/BookmarkView.tsx +45 -0
- package/src/notion/CustomKeymap.ts +48 -0
- package/src/notion/LinkDialog.tsx +133 -0
- package/src/notion/MapEditLayer.tsx +199 -0
- package/src/notion/MapNode.ts +220 -0
- package/src/notion/MapView.tsx +103 -0
- package/src/notion/NotionEditor.tsx +309 -0
- package/src/notion/README.md +158 -0
- package/src/notion/SlashExtension.ts +32 -0
- package/src/notion/SlashList.tsx +136 -0
- package/src/notion/TaskItemView.tsx +41 -0
- package/src/notion/createSlashSuggestion.ts +121 -0
- package/src/notion/extensions.ts +120 -0
- package/src/notion/index.ts +1 -0
- package/src/notion/lazy.tsx +44 -0
- package/src/notion/notion-editor.stories.tsx +293 -0
- package/src/notion/slashItems.ts +178 -0
- package/src/notion/styles.css +503 -0
- package/src/notion/types.ts +37 -0
- package/src/tiptap/index.ts +10 -0
- 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
|
+
}
|