@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,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><FilePathChip></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
|
+
});
|