@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,500 @@
1
+ import { useRef, useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import {
4
+ MarkdownEditor,
5
+ mentionPresets,
6
+ FilePathChip,
7
+ type MarkdownEditorHandle,
8
+ type MarkdownEditorProps,
9
+ type MentionConfig,
10
+ type MentionMarkdownRenderer,
11
+ } from './lazy';
12
+
13
+ // ── Fixtures ──
14
+
15
+ const SAMPLE = `# Hello, Markdown!
16
+
17
+ Type **bold**, *italic*, or \`inline code\`.
18
+
19
+ - Lists work too
20
+ - And [links](https://djangocfg.com)
21
+ `;
22
+
23
+ const LONG_CONTENT = `# Long Document
24
+
25
+ ## Introduction
26
+
27
+ This is a longer document used to verify scrolling, sticky toolbar behaviour, and
28
+ overall density of the editor surface.
29
+
30
+ ## Features
31
+
32
+ - Headings (H1–H3)
33
+ - **Bold**, *italic*, ~~strikethrough~~, \`inline code\`
34
+ - Ordered and unordered lists
35
+ - Blockquotes
36
+ - Links — [djangocfg](https://djangocfg.com)
37
+ - Horizontal rules
38
+
39
+ ---
40
+
41
+ ## Code
42
+
43
+ \`\`\`ts
44
+ export function greet(name: string) {
45
+ return \`hello, \${name}\`;
46
+ }
47
+ \`\`\`
48
+
49
+ ## Quote
50
+
51
+ > "The best way to predict the future is to invent it." — Alan Kay
52
+
53
+ ## Checklist
54
+
55
+ 1. Draft
56
+ 2. Review
57
+ 3. Ship
58
+
59
+ ## Filler
60
+
61
+ ${Array.from({ length: 12 }, (_, i) => `Paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`).join('\n\n')}
62
+ `;
63
+
64
+ const MENTION_ITEMS: MentionConfig = {
65
+ items: [
66
+ { id: '1', label: 'Alice', description: 'Protagonist', thumbnail: 'https://i.pravatar.cc/48?u=alice' },
67
+ { id: '2', label: 'Bob', description: 'Antagonist', thumbnail: 'https://i.pravatar.cc/48?u=bob' },
68
+ { id: '3', label: 'Charlie', description: 'Side character', thumbnail: 'https://i.pravatar.cc/48?u=charlie' },
69
+ { id: '4', label: 'Diana', description: 'Narrator' },
70
+ { id: '5', label: 'Eve', description: 'Mystery character' },
71
+ ],
72
+ };
73
+
74
+ // ── Meta ──
75
+
76
+ // ── Host (Controls-driven) ──
77
+
78
+ interface HostProps extends Omit<MarkdownEditorProps, 'value' | 'onChange'> {
79
+ initialContent?: string;
80
+ mentions?: MentionConfig;
81
+ }
82
+
83
+ function Host({ initialContent = '', mentions, ...rest }: HostProps) {
84
+ const [value, setValue] = useState(initialContent);
85
+ return (
86
+ <div className="w-[680px]">
87
+ <MarkdownEditor {...rest} mentions={mentions} value={value} onChange={setValue} />
88
+ </div>
89
+ );
90
+ }
91
+
92
+ const meta = {
93
+ title: 'Widgets/Editor/Editor',
94
+ component: Host,
95
+ tags: ['autodocs'],
96
+ parameters: {
97
+ layout: 'centered',
98
+ docs: {
99
+ description: {
100
+ component:
101
+ 'TipTap-based markdown editor. Supports headings, lists, marks, blockquotes, code, ' +
102
+ 'links, and an optional `@`-mention popover. The component is controlled — pass `value` ' +
103
+ 'and `onChange`. Use the `ref` (`MarkdownEditorHandle`) to focus or move the caret.',
104
+ },
105
+ },
106
+ },
107
+ argTypes: {
108
+ placeholder: {
109
+ control: 'text',
110
+ table: { category: 'Content' },
111
+ },
112
+ minHeight: {
113
+ control: { type: 'number', min: 40, max: 800, step: 20 },
114
+ table: { category: 'Layout' },
115
+ },
116
+ disabled: {
117
+ control: 'boolean',
118
+ table: { category: 'Behaviour' },
119
+ },
120
+ showToolbar: {
121
+ control: 'boolean',
122
+ table: { category: 'Behaviour' },
123
+ },
124
+ className: { table: { disable: true } },
125
+ mentions: { table: { disable: true } },
126
+ onMentionIdsChange: { table: { disable: true } },
127
+ onSubmit: { table: { disable: true } },
128
+ initialContent: {
129
+ control: 'text',
130
+ table: { category: 'Content' },
131
+ },
132
+ },
133
+ } satisfies Meta<typeof Host>;
134
+
135
+ export default meta;
136
+
137
+ type Story = StoryObj<typeof meta>;
138
+
139
+ // ── Playground ──
140
+
141
+ export const Playground: Story = {
142
+ args: {
143
+ placeholder: 'Write markdown…',
144
+ minHeight: 240,
145
+ disabled: false,
146
+ showToolbar: true,
147
+ },
148
+ render: ({ initialContent, ...args }) => (
149
+ <Host initialContent={initialContent ?? SAMPLE} {...args} />
150
+ ),
151
+ };
152
+
153
+ // ── Curated presets ──
154
+
155
+ export const Empty: Story = {
156
+ render: () => <Host initialContent="" placeholder="Start typing…" />,
157
+ };
158
+
159
+ export const WithContent: Story = {
160
+ render: () => <Host initialContent={SAMPLE} />,
161
+ };
162
+
163
+ export const ReadOnly: Story = {
164
+ render: () => (
165
+ <div className="w-[680px]">
166
+ <MarkdownEditor value={SAMPLE} onChange={() => {}} disabled />
167
+ </div>
168
+ ),
169
+ };
170
+
171
+ export const NoToolbar: Story = {
172
+ render: () => (
173
+ <Host
174
+ initialContent={'Plain text without toolbar.\n\nStill supports **markdown** shortcuts.'}
175
+ showToolbar={false}
176
+ />
177
+ ),
178
+ };
179
+
180
+ export const Compact: Story = {
181
+ render: () => (
182
+ <div className="w-[420px]">
183
+ <Host initialContent="Short note" minHeight={60} placeholder="Quick note…" />
184
+ </div>
185
+ ),
186
+ };
187
+
188
+ export const LongContent: Story = {
189
+ render: () => (
190
+ <div className="h-[520px] w-[680px] overflow-auto rounded-md border">
191
+ <Host initialContent={LONG_CONTENT} minHeight={480} />
192
+ </div>
193
+ ),
194
+ };
195
+
196
+ // ── Mentions ──
197
+
198
+ function MentionsHost({
199
+ initial,
200
+ renderMarkdown,
201
+ }: {
202
+ initial: string;
203
+ renderMarkdown?: MentionMarkdownRenderer;
204
+ }) {
205
+ const [value, setValue] = useState(initial);
206
+ const [ids, setIds] = useState<string[]>([]);
207
+ const mentions: MentionConfig = renderMarkdown
208
+ ? { ...MENTION_ITEMS, renderMarkdown }
209
+ : MENTION_ITEMS;
210
+
211
+ return (
212
+ <div className="grid w-[820px] gap-3" style={{ gridTemplateColumns: '1fr 1fr' }}>
213
+ <div>
214
+ <MarkdownEditor
215
+ value={value}
216
+ onChange={setValue}
217
+ mentions={mentions}
218
+ onMentionIdsChange={setIds}
219
+ placeholder="Type @ to mention…"
220
+ minHeight={200}
221
+ />
222
+ {ids.length > 0 && (
223
+ <div className="mt-2 text-xs opacity-60">Mentioned IDs: {ids.join(', ')}</div>
224
+ )}
225
+ </div>
226
+ <div>
227
+ <div className="mb-1 text-[10px] uppercase tracking-wide opacity-50">
228
+ Serialized markdown
229
+ </div>
230
+ <pre className="max-h-[260px] overflow-auto rounded-md border bg-muted/40 p-2 text-xs">
231
+ {value}
232
+ </pre>
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+
238
+ export const WithMentions: Story = {
239
+ render: () => (
240
+ <MentionsHost initial="Hello @Alice! Type @ to mention more characters." />
241
+ ),
242
+ };
243
+
244
+ export const WithCustomUriPreset: Story = {
245
+ name: 'Mentions / customUri preset',
246
+ render: () => (
247
+ <MentionsHost
248
+ initial="Reference @Alice and @Bob — markdown carries deep-link URIs."
249
+ renderMarkdown={mentionPresets.customUri('myapp', 'user')}
250
+ />
251
+ ),
252
+ };
253
+
254
+ export const WithMarkdownLinkPreset: Story = {
255
+ name: 'Mentions / markdownLink preset',
256
+ render: () => (
257
+ <MentionsHost
258
+ initial="Ping @Alice when ready, cc @Charlie."
259
+ renderMarkdown={mentionPresets.markdownLink('https://example.com/u/')}
260
+ />
261
+ ),
262
+ };
263
+
264
+ export const WithCustomRenderer: Story = {
265
+ name: 'Mentions / custom renderer',
266
+ render: () => (
267
+ <MentionsHost
268
+ initial="Tag @Alice and @Bob to assign the task."
269
+ renderMarkdown={({ id, label }) => `{{user:${id}|${label || 'unknown'}}}`}
270
+ />
271
+ ),
272
+ };
273
+
274
+ // ── Imperative handle ──
275
+
276
+ export const ImperativeHandle: Story = {
277
+ name: 'Ref / focus & moveCursorToEnd',
278
+ render: () => {
279
+ function HandleDemo() {
280
+ const ref = useRef<MarkdownEditorHandle>(null);
281
+ const [value, setValue] = useState(SAMPLE);
282
+ return (
283
+ <div className="w-[680px] space-y-2">
284
+ <div className="flex gap-2">
285
+ <button
286
+ type="button"
287
+ className="rounded-md border px-3 py-1 text-xs"
288
+ onClick={() => ref.current?.focus()}
289
+ >
290
+ focus()
291
+ </button>
292
+ <button
293
+ type="button"
294
+ className="rounded-md border px-3 py-1 text-xs"
295
+ onClick={() => ref.current?.moveCursorToEnd()}
296
+ >
297
+ moveCursorToEnd()
298
+ </button>
299
+ </div>
300
+ <MarkdownEditor ref={ref} value={value} onChange={setValue} minHeight={200} />
301
+ </div>
302
+ );
303
+ }
304
+ return <HandleDemo />;
305
+ },
306
+ };
307
+
308
+ // ── File-path chips ──
309
+
310
+ const FILE_PATH_REPRO =
311
+ 'прочти манифест /Users/markinmatrix/workspace/@projects/cmdop/@docs/positioning/manifest.md интересно твоё мнение';
312
+
313
+ const LONG_PATH =
314
+ '/Users/markinmatrix/workspace/@projects/djangocfg/projects/solution/frontend/packages/ui-tools/src/tools/dev/Map/index.ts';
315
+
316
+ const FILE_PATH_MIX = [
317
+ 'Repro: ' + FILE_PATH_REPRO,
318
+ '',
319
+ 'Long path (was wrapping): ' + LONG_PATH,
320
+ '',
321
+ 'Windows: C:\\Users\\me\\notes.md',
322
+ 'UNC: \\\\srv\\share\\report.xlsx',
323
+ 'Home: ~/dev/app/src/index.ts',
324
+ 'URI: file:///etc/hosts',
325
+ 'Folder: /Users/me/workspace/positioning',
326
+ '',
327
+ 'Prose stays plain: a / b stays text.',
328
+ ].join('\n');
329
+
330
+ /**
331
+ * Host that shows the editor side-by-side with the serialized markdown so
332
+ * you can confirm chips round-trip to the RAW path / URL on copy / submit.
333
+ */
334
+ function ChipRoundTripHost({
335
+ initial,
336
+ filePathChips,
337
+ urlChips,
338
+ minHeight = 220,
339
+ }: {
340
+ initial: string;
341
+ filePathChips?: boolean;
342
+ urlChips?: boolean;
343
+ minHeight?: number;
344
+ }) {
345
+ const [value, setValue] = useState(initial);
346
+ return (
347
+ <div className="grid w-[860px] gap-3" style={{ gridTemplateColumns: '1fr 1fr' }}>
348
+ <MarkdownEditor
349
+ value={value}
350
+ onChange={setValue}
351
+ minHeight={minHeight}
352
+ filePathChips={filePathChips}
353
+ urlChips={urlChips}
354
+ />
355
+ <div>
356
+ <div className="mb-1 text-[10px] uppercase tracking-wide opacity-50">
357
+ getMarkdown() output (raw path / URL — never chip markup)
358
+ </div>
359
+ <pre className="max-h-[280px] overflow-auto rounded-md border bg-muted/40 p-2 text-xs whitespace-pre-wrap break-all">
360
+ {value}
361
+ </pre>
362
+ </div>
363
+ </div>
364
+ );
365
+ }
366
+
367
+ export const FilePathChips: Story = {
368
+ name: 'File paths / chips (atomic node)',
369
+ render: () => (
370
+ <div className="space-y-2">
371
+ <p className="w-[860px] text-xs opacity-60">
372
+ Absolute local paths render as compact, atomic chips (icon +
373
+ VSCode-style middle-ellipsis label, full path on hover). The chip is a
374
+ NODE that owns its rendering, so a long path collapses to ONE line
375
+ instead of wrapping. Copy / submit / <code>getMarkdown()</code> keep
376
+ the raw path — see the panel on the right. Type a path + space to
377
+ watch it become a chip.
378
+ </p>
379
+ <ChipRoundTripHost initial={FILE_PATH_MIX} minHeight={300} />
380
+ </div>
381
+ ),
382
+ };
383
+
384
+ // ── URL chips ──
385
+
386
+ const URL_MIX = [
387
+ 'Repo: https://github.com/wailsapp/wails/blob/main/v3/README.md',
388
+ '',
389
+ 'Docs: https://djangocfg.com/docs/getting-started',
390
+ 'Bare host: www.example.com/path/to/page',
391
+ 'Mail: mailto:hello@djangocfg.com',
392
+ '',
393
+ 'Type a long URL + space to watch it become a chip.',
394
+ ].join('\n');
395
+
396
+ export const UrlChips: Story = {
397
+ name: 'URLs / chips (atomic node)',
398
+ render: () => (
399
+ <div className="space-y-2">
400
+ <p className="w-[860px] text-xs opacity-60">
401
+ URLs render as compact, clickable chips (favicon — degrading to a
402
+ globe glyph — + domain and a middle-ellipsis of the path, e.g.{' '}
403
+ <code>github.com/…/README.md</code>; full URL on hover). Replaces the
404
+ plain blue underlined link. Copy / submit keep the raw URL — see the
405
+ panel on the right.
406
+ </p>
407
+ <ChipRoundTripHost initial={URL_MIX} minHeight={260} />
408
+ </div>
409
+ ),
410
+ };
411
+
412
+ export const UrlChipsOff: Story = {
413
+ name: 'URLs / disabled (urlChips=false)',
414
+ render: () => (
415
+ <ChipRoundTripHost
416
+ initial={'Plain link stays a markdown link: https://djangocfg.com/docs'}
417
+ urlChips={false}
418
+ minHeight={140}
419
+ />
420
+ ),
421
+ };
422
+
423
+ export const FilePathChipsOff: Story = {
424
+ name: 'File paths / disabled (filePathChips=false)',
425
+ render: () => {
426
+ function Off() {
427
+ const [value, setValue] = useState(FILE_PATH_REPRO);
428
+ return (
429
+ <div className="w-[680px]">
430
+ <MarkdownEditor
431
+ value={value}
432
+ onChange={setValue}
433
+ filePathChips={false}
434
+ minHeight={140}
435
+ />
436
+ </div>
437
+ );
438
+ }
439
+ return <Off />;
440
+ },
441
+ };
442
+
443
+ // ── Standalone chip (outside the editor) ──
444
+
445
+ export const StandaloneFilePathChip: Story = {
446
+ name: 'File paths / standalone <FilePathChip>',
447
+ render: () => (
448
+ <div className="flex w-[520px] flex-col items-start gap-3">
449
+ <p className="text-xs opacity-60">
450
+ <code>&lt;FilePathChip&gt;</code> is presentational and works outside
451
+ the editor — e.g. rendering a known path in a message.
452
+ </p>
453
+ <FilePathChip path="/Users/me/manifest.md" />
454
+ <FilePathChip path="/Users/markinmatrix/workspace/@projects/cmdop/@docs/positioning/manifest.md" />
455
+ <FilePathChip path={'C:\\Users\\me\\notes.md'} />
456
+ <FilePathChip path="~/dev/app/src/index.ts" />
457
+ <FilePathChip path="/Users/me/workspace/positioning" />
458
+ </div>
459
+ ),
460
+ };
461
+
462
+ // ── Submit on Enter (chat-style) ──
463
+
464
+ export const SubmitOnEnter: Story = {
465
+ name: 'Behaviour / submit on Enter',
466
+ render: () => {
467
+ function ChatLike() {
468
+ const [value, setValue] = useState('Press Enter to submit, Shift+Enter for newline.');
469
+ const [log, setLog] = useState<string[]>([]);
470
+ return (
471
+ <div className="w-[680px] space-y-2">
472
+ <MarkdownEditor
473
+ value={value}
474
+ onChange={setValue}
475
+ minHeight={140}
476
+ placeholder="Type a message…"
477
+ onSubmit={() => {
478
+ if (!value.trim()) return false; // let HardBreak fall through
479
+ setLog((prev) => [...prev, value]);
480
+ setValue('');
481
+ }}
482
+ />
483
+ {log.length > 0 && (
484
+ <div className="rounded-md border bg-muted/30 p-2 text-xs">
485
+ <div className="mb-1 text-[10px] uppercase tracking-wide opacity-50">Submitted</div>
486
+ <ul className="space-y-1">
487
+ {log.map((m, i) => (
488
+ <li key={i} className="font-mono">
489
+ {m}
490
+ </li>
491
+ ))}
492
+ </ul>
493
+ </div>
494
+ )}
495
+ </div>
496
+ );
497
+ }
498
+ return <ChatLike />;
499
+ },
500
+ };
@@ -0,0 +1,49 @@
1
+ import type { MentionMarkdownRenderer } from './types';
2
+
3
+ /**
4
+ * Escape characters that have meaning in markdown link/inline contexts.
5
+ * Conservative — covers the chars that would break `[text](url)` and
6
+ * inline emphasis when a label contains them.
7
+ */
8
+ const escapeMd = (s: string): string => s.replace(/([\\\[\]()_*~`])/g, '\\$1');
9
+
10
+ /**
11
+ * Built-in serializers for the `MentionConfig.renderMarkdown` callback.
12
+ *
13
+ * Pick one based on what consumes the markdown:
14
+ *
15
+ * - LLM / chat composer → `plainAt` (the default)
16
+ * - Plain text export → `plainLabel`
17
+ * - Web app with deep-link → `markdownLink(baseUrl)`
18
+ * - Notion / Linear-style → `customUri(scheme, kind)`
19
+ * - Slack-style id refs → `slackStyle`
20
+ * - HTML-allowing renderer → `htmlSpan(className?)`
21
+ *
22
+ * Or pass a custom function — the type is just `(attrs) => string`.
23
+ */
24
+ export const mentionPresets = {
25
+ /** "@Label" — default, ideal for chat where LLMs read the text. */
26
+ plainAt: (({ label, id }) => `@${label || id}`) as MentionMarkdownRenderer,
27
+
28
+ /** "Label" — bare label, no @ prefix. */
29
+ plainLabel: (({ label, id }) => label || id) as MentionMarkdownRenderer,
30
+
31
+ /** "[@Label](baseUrl/id)" — clickable markdown link. */
32
+ markdownLink: (baseUrl: string): MentionMarkdownRenderer =>
33
+ ({ label, id }) =>
34
+ `[@${escapeMd(label || id)}](${baseUrl}${encodeURIComponent(id)})`,
35
+
36
+ /** "@[Label](scheme://kind/id)" — Notion / Linear-style custom URI. */
37
+ customUri: (scheme: string, kind: string): MentionMarkdownRenderer =>
38
+ ({ label, id }) =>
39
+ `@[${escapeMd(label || id)}](${scheme}://${kind}/${encodeURIComponent(id)})`,
40
+
41
+ /** "<@id>" — Slack-style id-only reference (label dropped — receivers resolve it). */
42
+ slackStyle: (({ id }) => `<@${id}>`) as MentionMarkdownRenderer,
43
+
44
+ /** Inline HTML span — for products that consume markdown with raw HTML allowed. */
45
+ htmlSpan:
46
+ (className = 'mention'): MentionMarkdownRenderer =>
47
+ ({ label, id }) =>
48
+ `<span class="${className}" data-mention-id="${encodeURIComponent(id)}">@${escapeMd(label || id)}</span>`,
49
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `SlashCommandNode` — TipTap inline atom that renders a chosen slash
3
+ * verb as a styled chip inside the editor, analogous to how
4
+ * `@tiptap/extension-mention` renders an `@user` pill.
5
+ *
6
+ * Why a node (and not a mark / decoration):
7
+ *
8
+ * - The chip must read as one indivisible unit — pressing Backspace
9
+ * removes the whole `/verb`, not the trailing letter. That's
10
+ * exactly what `atom: true` + `inline: true` give us.
11
+ * - The chip's text content (`/verb`) participates in `editor.getText()`
12
+ * and the markdown serializer's text output so the host's
13
+ * `composer.value` round-trips to the same plain string the plain
14
+ * `<SlashHighlightTextarea>` produces. The slash hook (which lives on
15
+ * the string buffer) stays oblivious to the node form.
16
+ *
17
+ * Render parity with the plain mirror:
18
+ *
19
+ * The chip class `markdown-slash-command` mirrors the plain textarea's
20
+ * `bg-primary/15 text-primary` look (see `SlashHighlightTextarea.tsx`).
21
+ * Styles live in `../styles.css`. The TipTap chip can carry a touch of
22
+ * horizontal padding without breaking the caret — the node is atomic
23
+ * so the caret never lands inside it — which the plain mirror cannot.
24
+ */
25
+ import { Node, mergeAttributes } from '@tiptap/core';
26
+
27
+ declare module '@tiptap/core' {
28
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
29
+ interface Commands<ReturnType> {
30
+ slashCommand: {
31
+ /**
32
+ * Insert a slash-command atom at the given absolute document
33
+ * position. The host normally calls this once per `pick()`
34
+ * after the slash hook has produced the new string buffer.
35
+ */
36
+ insertSlashCommandAt: (
37
+ pos: number,
38
+ from: number,
39
+ to: number,
40
+ attrs: { id: string; token: string },
41
+ ) => ReturnType;
42
+ };
43
+ }
44
+ }
45
+
46
+ export interface SlashCommandNodeOptions {
47
+ /** Extra HTML attrs spread onto the rendered `<span>`. */
48
+ HTMLAttributes: Record<string, unknown>;
49
+ }
50
+
51
+ /**
52
+ * Inline atom node that paints a `/verb` chip inside the TipTap editor.
53
+ *
54
+ * - `inline: true` — lives next to text in a paragraph.
55
+ * - `atom: true` — caret cannot land inside it; Backspace removes it
56
+ * wholesale, matching the plain mirror's "the verb is one token"
57
+ * feel.
58
+ * - `selectable: false` — clicking the chip does not stick a node
59
+ * selection on it (mention does the same).
60
+ *
61
+ * `renderText` returns the bare `/verb` so `editor.getText()` (and
62
+ * therefore the host `composer.value`) reads the same string the plain
63
+ * mirror produces. This is what keeps the slash hook — which only
64
+ * understands strings — driving the menu correctly when the editor is
65
+ * TipTap-backed.
66
+ */
67
+ export const SlashCommandNode = Node.create<SlashCommandNodeOptions>({
68
+ name: 'slashCommand',
69
+ group: 'inline',
70
+ inline: true,
71
+ atom: true,
72
+ selectable: false,
73
+ // Higher than StarterKit text so paste rules etc. don't try to
74
+ // re-parse our chip text as plain.
75
+ priority: 101,
76
+
77
+ addOptions() {
78
+ return {
79
+ HTMLAttributes: {},
80
+ };
81
+ },
82
+
83
+ addAttributes() {
84
+ return {
85
+ id: {
86
+ default: null,
87
+ parseHTML: (element) => element.getAttribute('data-id'),
88
+ renderHTML: (attrs) =>
89
+ attrs.id ? { 'data-id': attrs.id as string } : {},
90
+ },
91
+ token: {
92
+ default: null,
93
+ parseHTML: (element) => element.getAttribute('data-token'),
94
+ renderHTML: (attrs) =>
95
+ attrs.token ? { 'data-token': attrs.token as string } : {},
96
+ },
97
+ };
98
+ },
99
+
100
+ parseHTML() {
101
+ return [{ tag: `span[data-type="${this.name}"]` }];
102
+ },
103
+
104
+ renderHTML({ node, HTMLAttributes }) {
105
+ const token =
106
+ (node.attrs.token as string | null) ??
107
+ (node.attrs.id ? `/${node.attrs.id as string}` : '/');
108
+ return [
109
+ 'span',
110
+ mergeAttributes(
111
+ { 'data-type': this.name, class: 'markdown-slash-command' },
112
+ this.options.HTMLAttributes,
113
+ HTMLAttributes,
114
+ ),
115
+ token,
116
+ ];
117
+ },
118
+
119
+ // `editor.getText()` and the markdown text-serializer fallback read
120
+ // this — the chip flattens to its raw `/verb` glyph so consumers
121
+ // (slash hook, transport, backends) see plain text.
122
+ renderText({ node }) {
123
+ const token =
124
+ (node.attrs.token as string | null) ??
125
+ (node.attrs.id ? `/${node.attrs.id as string}` : '/');
126
+ return token;
127
+ },
128
+
129
+ // `@tiptap/markdown` reads `renderMarkdown` off the extension at
130
+ // registration time. We emit the bare token, NOT the default
131
+ // shortcode `[slashCommand id="..."]`, so a round-trip through
132
+ // `getMarkdown()` yields the same `/verb argument` string the plain
133
+ // mirror produces. Slash commands never need re-parsed back from
134
+ // markdown — fresh value sync handles that via
135
+ // `syncLeadingSlashNode` after each `setContent`.
136
+ renderMarkdown(node) {
137
+ const attrs = node.attrs as { token?: string | null; id?: string | null };
138
+ const token = attrs.token ?? (attrs.id ? `/${attrs.id}` : '');
139
+ return token;
140
+ },
141
+
142
+ addCommands() {
143
+ return {
144
+ insertSlashCommandAt:
145
+ (pos, from, to, attrs) =>
146
+ ({ chain }) => {
147
+ // Replace the [from, to) range (typically the leading `/verb`
148
+ // text the user just had the menu narrow down) with the atom
149
+ // node. `pos` is unused here but kept in the signature for
150
+ // future use (e.g. cursor-driven insertion not anchored at
151
+ // the start of the doc).
152
+ void pos;
153
+ return chain()
154
+ .insertContentAt(
155
+ { from, to },
156
+ { type: 'slashCommand', attrs },
157
+ )
158
+ .run();
159
+ },
160
+ };
161
+ },
162
+ });