@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,106 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useState,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
type KeyboardEvent,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import type { MentionItem } from './types';
|
|
13
|
+
|
|
14
|
+
export interface MentionListRef {
|
|
15
|
+
onKeyDown: (event: KeyboardEvent) => boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MentionListProps {
|
|
19
|
+
items: MentionItem[];
|
|
20
|
+
command: (item: MentionItem) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
|
24
|
+
({ items, command }, ref) => {
|
|
25
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
26
|
+
// One ref per rendered option so keyboard nav can scroll the active
|
|
27
|
+
// item into view inside the (scrollable, max-height) dropdown.
|
|
28
|
+
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => setSelectedIndex(0), [items]);
|
|
31
|
+
|
|
32
|
+
// Keep the highlighted item visible when ArrowUp/Down moves the
|
|
33
|
+
// selection past the visible window of the dropdown.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
|
36
|
+
}, [selectedIndex]);
|
|
37
|
+
|
|
38
|
+
const select = useCallback(
|
|
39
|
+
(index: number) => {
|
|
40
|
+
const item = items[index];
|
|
41
|
+
if (item) command(item);
|
|
42
|
+
},
|
|
43
|
+
[items, command],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
useImperativeHandle(ref, () => ({
|
|
47
|
+
onKeyDown: (event: KeyboardEvent) => {
|
|
48
|
+
if (items.length === 0) return false;
|
|
49
|
+
if (event.key === 'ArrowUp') {
|
|
50
|
+
setSelectedIndex((i) => (i + items.length - 1) % items.length);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (event.key === 'ArrowDown') {
|
|
54
|
+
setSelectedIndex((i) => (i + 1) % items.length);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
// Tab commits the highlighted item too — matches GitHub / Slack /
|
|
58
|
+
// ChatGPT mention pickers. Returning true keeps focus in the editor.
|
|
59
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
60
|
+
select(selectedIndex);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
if (items.length === 0) return null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="markdown-mention-list" role="listbox" aria-label="Mention suggestions">
|
|
71
|
+
{items.map((item, i) => {
|
|
72
|
+
const isSelected = i === selectedIndex;
|
|
73
|
+
const cls = `markdown-mention-item ${isSelected ? 'selected' : ''}`;
|
|
74
|
+
return (
|
|
75
|
+
<button
|
|
76
|
+
key={item.id}
|
|
77
|
+
type="button"
|
|
78
|
+
role="option"
|
|
79
|
+
aria-selected={isSelected}
|
|
80
|
+
ref={(el) => {
|
|
81
|
+
itemRefs.current[i] = el;
|
|
82
|
+
}}
|
|
83
|
+
className={cls}
|
|
84
|
+
// Pointer-driven selection mirrors the keyboard highlight so
|
|
85
|
+
// hovering then clicking never picks a different item.
|
|
86
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
87
|
+
onClick={() => select(i)}
|
|
88
|
+
>
|
|
89
|
+
{item.thumbnail && (
|
|
90
|
+
<img src={item.thumbnail} alt="" className="markdown-mention-avatar" />
|
|
91
|
+
)}
|
|
92
|
+
<div className="markdown-mention-info">
|
|
93
|
+
<span className="markdown-mention-name">{item.label}</span>
|
|
94
|
+
{item.description && (
|
|
95
|
+
<span className="markdown-mention-desc">{item.description}</span>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</button>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
MentionList.displayName = 'MentionList';
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# MarkdownEditor
|
|
2
|
+
|
|
3
|
+
WYSIWYG markdown editor based on Tiptap. Renders markdown visually (headings, lists, blockquotes) while editing, serializes to markdown string.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Visual headings (H1, H2, H3) with proper sizing
|
|
8
|
+
- Bold, italic, strikethrough, inline code
|
|
9
|
+
- Bullet and ordered lists
|
|
10
|
+
- Blockquotes with left border
|
|
11
|
+
- Horizontal rules
|
|
12
|
+
- Toolbar with icon buttons
|
|
13
|
+
- `@`-mentions with auto-flipping popup (`@floating-ui/dom`)
|
|
14
|
+
- Customizable markdown serialization for mentions (six built-in presets)
|
|
15
|
+
- File-path chips and URL chips — atomic inline nodes with VSCode-style
|
|
16
|
+
middle-ellipsis truncation (see [File-path & URL chips](#file-path--url-chips))
|
|
17
|
+
- Markdown input/output (stored as plain markdown string)
|
|
18
|
+
- SSR-safe (`immediatelyRender: false`)
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { MarkdownEditor } from '@djangocfg/ui-tools/markdown-editor';
|
|
24
|
+
|
|
25
|
+
function MyComponent() {
|
|
26
|
+
const [bio, setBio] = useState('# Hello\n\nThis is **markdown**.');
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<MarkdownEditor
|
|
30
|
+
value={bio}
|
|
31
|
+
onChange={setBio}
|
|
32
|
+
placeholder="Write something..."
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CSS
|
|
39
|
+
|
|
40
|
+
Two ways to load styles, depending on your build:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// Tailwind-based apps — pulls in source utilities so Tailwind's JIT picks them up.
|
|
44
|
+
import '@djangocfg/ui-tools/styles';
|
|
45
|
+
|
|
46
|
+
// Plain Vite / webpack / CRA — pre-compiled CSS, no Tailwind required.
|
|
47
|
+
import '@djangocfg/ui-tools/dist.css';
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Props
|
|
51
|
+
|
|
52
|
+
| Prop | Type | Default | Description |
|
|
53
|
+
|------|------|---------|-------------|
|
|
54
|
+
| `value` | `string` | — | Markdown string |
|
|
55
|
+
| `onChange` | `(value: string) => void` | — | Called on every change |
|
|
56
|
+
| `placeholder` | `string` | `'Write markdown...'` | Placeholder text |
|
|
57
|
+
| `minHeight` | `number` | `120` | Min height in px |
|
|
58
|
+
| `className` | `string` | — | Additional CSS class |
|
|
59
|
+
| `disabled` | `boolean` | `false` | Read-only mode |
|
|
60
|
+
| `showToolbar` | `boolean` | `true` | Show formatting toolbar |
|
|
61
|
+
| `mentions` | `MentionConfig` | — | `@`-mention autocomplete config |
|
|
62
|
+
| `filePathChips` | `boolean` | `true` | Render absolute local file paths as atomic chips — see [File-path & URL chips](#file-path--url-chips) |
|
|
63
|
+
| `urlChips` | `boolean` | `true` | Render web URLs as atomic, clickable chips — see [File-path & URL chips](#file-path--url-chips) |
|
|
64
|
+
| `onMentionIdsChange` | `(ids: string[]) => void` | — | Called when mentioned IDs change |
|
|
65
|
+
| `onSubmit` | `() => boolean \| void` | — | Enter handler — when set, Enter submits and Shift+Enter inserts a newline (ChatGPT / Telegram chat behaviour). Return `false` to fall back to default HardBreak. See [Submit on Enter](#submit-on-enter) below. |
|
|
66
|
+
|
|
67
|
+
### `MentionConfig` fields
|
|
68
|
+
|
|
69
|
+
| Field | Type | Default | Description |
|
|
70
|
+
|-------|------|---------|-------------|
|
|
71
|
+
| `items` | `MentionItem[]` | required | Available mention items (`id`, `label`, optional `description`, `thumbnail`) |
|
|
72
|
+
| `trigger` | `string` | `'@'` | Trigger character |
|
|
73
|
+
| `maxItems` | `number` | `5` | Max items shown in dropdown |
|
|
74
|
+
| `renderMarkdown` | `MentionMarkdownRenderer` | `mentionPresets.plainAt` | Serializes a mention to markdown — see below |
|
|
75
|
+
|
|
76
|
+
## Mentions
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
<MarkdownEditor
|
|
80
|
+
value={text}
|
|
81
|
+
onChange={setText}
|
|
82
|
+
mentions={{
|
|
83
|
+
items: [
|
|
84
|
+
{ id: '1', label: 'Alice', thumbnail: '/alice.jpg' },
|
|
85
|
+
{ id: '2', label: 'Bob', description: 'Antagonist' },
|
|
86
|
+
],
|
|
87
|
+
}}
|
|
88
|
+
onMentionIdsChange={(ids) => console.log('Mentioned:', ids)}
|
|
89
|
+
/>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Type `@` to trigger autocomplete. Mentions render as inline chips. The popup auto-flips above the trigger when there isn't enough room below, and tracks scroll/resize via `autoUpdate` — viewport-aware out of the box.
|
|
93
|
+
|
|
94
|
+
> Heads-up: Tiptap initialises the editor exactly once. If `mentions` is `undefined` on first render and becomes truthy later, the extension is never installed. Pass `{ items: [] }` from the start and mutate `.items` in place if items load async.
|
|
95
|
+
|
|
96
|
+
## Mention serialization
|
|
97
|
+
|
|
98
|
+
By default Tiptap's `Mention` extension emits a useless shortcode like `[@ id="..." label="..."]` into markdown. `MarkdownEditor` overrides that with the renderer from `MentionConfig.renderMarkdown` (defaults to `mentionPresets.plainAt`, which yields `@<label>`).
|
|
99
|
+
|
|
100
|
+
Six presets are exported from `@djangocfg/ui-tools`:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { MarkdownEditor, mentionPresets } from '@djangocfg/ui-tools/markdown-editor';
|
|
104
|
+
|
|
105
|
+
<MarkdownEditor
|
|
106
|
+
value={text}
|
|
107
|
+
onChange={setText}
|
|
108
|
+
mentions={{
|
|
109
|
+
items,
|
|
110
|
+
renderMarkdown: mentionPresets.customUri('cmdop', 'machine'),
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Preset | Output for `{ label: 'Vps-audi', id: 'uuid' }` |
|
|
116
|
+
|--------|-------------------------------------------------|
|
|
117
|
+
| `plainAt` *(default)* | `@Vps-audi` |
|
|
118
|
+
| `plainLabel` | `Vps-audi` |
|
|
119
|
+
| `markdownLink(baseUrl)` | `[@Vps-audi](baseUrl/uuid)` |
|
|
120
|
+
| `customUri(scheme, kind)` | `@[Vps-audi](scheme://kind/uuid)` |
|
|
121
|
+
| `slackStyle` | `<@uuid>` |
|
|
122
|
+
| `htmlSpan(className?)` | `<span class="mention" data-mention-id="uuid">@Vps-audi</span>` |
|
|
123
|
+
|
|
124
|
+
Pick by use case:
|
|
125
|
+
|
|
126
|
+
- **`plainAt`** — chat composers feeding an LLM that reads `@<label>` natively.
|
|
127
|
+
- **`customUri`** — chat that also wants machine-readable IDs in `href` for downstream parsing (e.g. `@[Vps-audi](cmdop://machine/uuid)`).
|
|
128
|
+
- **`markdownLink`** — clickable mentions linking to a profile / detail page.
|
|
129
|
+
- **`slackStyle`** — interop with Slack-like backends that resolve `<@id>` server-side.
|
|
130
|
+
- **`htmlSpan`** — markdown consumers that allow inline HTML and want chip styling baked in.
|
|
131
|
+
- **`plainLabel`** — bare display string (no `@`), e.g. for `/`-commands or non-mention triggers.
|
|
132
|
+
|
|
133
|
+
### Custom renderer
|
|
134
|
+
|
|
135
|
+
The signature is just `(attrs: MentionAttrs) => string`:
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
import type { MentionMarkdownRenderer } from '@djangocfg/ui-tools/markdown-editor';
|
|
139
|
+
|
|
140
|
+
const renderMention: MentionMarkdownRenderer = ({ id, label }) =>
|
|
141
|
+
`{{user:${id}|${label}}}`;
|
|
142
|
+
|
|
143
|
+
<MarkdownEditor mentions={{ items, renderMarkdown: renderMention }} ... />
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Either `id` or `label` may be empty strings if upstream config didn't populate them — fall back accordingly. Returning `''` drops the mention from the output.
|
|
147
|
+
|
|
148
|
+
> Mentions are write-only: the markdown isn't parsed back into mention nodes on `setContent`. After submit/reset, the editor receives a plain string — fine for chat composers.
|
|
149
|
+
|
|
150
|
+
## File-path & URL chips
|
|
151
|
+
|
|
152
|
+
The editor turns absolute **local file paths** and **web URLs** into compact,
|
|
153
|
+
atomic inline chips — like VSCode / Cursor. Both default ON; gate them with
|
|
154
|
+
the `filePathChips` / `urlChips` props.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
<MarkdownEditor
|
|
158
|
+
value={text}
|
|
159
|
+
onChange={setText}
|
|
160
|
+
filePathChips // default true — paths → chips
|
|
161
|
+
urlChips // default true — URLs → chips
|
|
162
|
+
/>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### What you get
|
|
166
|
+
|
|
167
|
+
| | File-path chip (`kind: 'path'`) | URL chip (`kind: 'url'`) |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| Icon | VSCode file icon by extension (folder glyph for dirs) | favicon (Google S2) → globe glyph on error |
|
|
170
|
+
| Label | middle-ellipsis, basename kept — `…/dev/Map/index.ts` | domain + middle-ellipsis path — `github.com/…/README.md` |
|
|
171
|
+
| Tooltip | full path (`title`) | full URL (`title`) |
|
|
172
|
+
| Clickable | no | yes — opens in a new tab (`rel=noopener noreferrer`) |
|
|
173
|
+
| Detection | absolute Unix / macOS / `~` / Windows-drive / UNC / `file://` | `http`/`https`/`ftp`/`mailto`/`file` + bare `www.` |
|
|
174
|
+
|
|
175
|
+
### Why a node, not a decoration (the long-path fix)
|
|
176
|
+
|
|
177
|
+
The chip is an **atomic inline NODE** (`editorChip`), the same shape as the
|
|
178
|
+
`@`-mention and `/`-command chips — NOT a decoration. A decoration is an
|
|
179
|
+
overlay: it can style the raw text but can't replace it, so a long path
|
|
180
|
+
rendered in full and **wrapped to several lines**. A node owns its
|
|
181
|
+
rendering, so it shows the truncated label on **one line** while the full
|
|
182
|
+
string lives in the node's attribute.
|
|
183
|
+
|
|
184
|
+
A single node renders both kinds (a `kind` attribute switches path vs URL) —
|
|
185
|
+
one component, one set of styles, DRY.
|
|
186
|
+
|
|
187
|
+
### Text / markdown fidelity (the whole point)
|
|
188
|
+
|
|
189
|
+
Each chip **serialises back to the RAW string**. `renderText` and
|
|
190
|
+
`renderMarkdown` both emit the literal path / URL, so:
|
|
191
|
+
|
|
192
|
+
- copying a chip yields the raw path / URL,
|
|
193
|
+
- the submitted message and `getMarkdown()` contain the real string,
|
|
194
|
+
- never any chip markup.
|
|
195
|
+
|
|
196
|
+
A bare URL serialises as the bare URL; a file path as the literal path.
|
|
197
|
+
|
|
198
|
+
### Auto-conversion
|
|
199
|
+
|
|
200
|
+
- **Typing**: type a path / URL followed by a **space** and it becomes a
|
|
201
|
+
chip (an input rule on the word boundary — punctuation inside the token
|
|
202
|
+
like `.ts` is preserved because only whitespace triggers conversion).
|
|
203
|
+
- **Pasting**: a paste rule converts every detected path / URL in the
|
|
204
|
+
pasted text, keeping the surrounding prose.
|
|
205
|
+
- **Initial `value`**: paths / URLs in the controlled markdown are
|
|
206
|
+
converted after `setContent` (the same way leading `/`-commands are).
|
|
207
|
+
- **External controlled update while editing**: the editor preserves the
|
|
208
|
+
current focused selection, clamped to the new document. A background parent
|
|
209
|
+
render must never send the caret to document start or steal focus from
|
|
210
|
+
another control.
|
|
211
|
+
|
|
212
|
+
### Caret / editing
|
|
213
|
+
|
|
214
|
+
Atomic nodes behave like a single character: arrow keys step over the whole
|
|
215
|
+
chip, and Backspace removes / reverts the whole chip at once (the raw text
|
|
216
|
+
is never partially lost) — VSCode-like.
|
|
217
|
+
|
|
218
|
+
### Standalone `<FilePathChip>`
|
|
219
|
+
|
|
220
|
+
The presentational React chip still exists for rendering a known path
|
|
221
|
+
**outside** the editor (a chat message, a file list). It's exported from
|
|
222
|
+
`@djangocfg/ui-tools/markdown-editor`:
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
import { FilePathChip } from '@djangocfg/ui-tools/markdown-editor';
|
|
226
|
+
|
|
227
|
+
<FilePathChip path="/Users/me/dev/Map/index.ts" />
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The pure detector / display helpers are exported too:
|
|
231
|
+
`findFilePaths`, `splitPath`, `truncatePathLabel` (paths) and
|
|
232
|
+
`findUrls`, `splitUrl`, `truncateUrlLabel`, `faviconUrl` (URLs).
|
|
233
|
+
|
|
234
|
+
## Submit on Enter
|
|
235
|
+
|
|
236
|
+
For chat composers you usually want **Enter = send**, **Shift+Enter = newline** — ChatGPT / Telegram / Slack behaviour. Pass an `onSubmit` and that's what you get:
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
<MarkdownEditor
|
|
240
|
+
value={text}
|
|
241
|
+
onChange={setText}
|
|
242
|
+
onSubmit={() => {
|
|
243
|
+
if (!text.trim()) return false // fall back to HardBreak
|
|
244
|
+
void send(text)
|
|
245
|
+
// returning undefined (or true) consumes the key — newline isn't inserted
|
|
246
|
+
}}
|
|
247
|
+
/>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### How it works (and why a Tiptap extension, not a React onKeyDown)
|
|
251
|
+
|
|
252
|
+
The implementation lives in `submitOnEnter.ts` — a Tiptap `Extension` that registers a keyboard shortcut via `addKeyboardShortcuts`. It runs **inside ProseMirror's keymap pipeline at higher priority than StarterKit's HardBreak**, so we intercept Enter **before** the hard-break transaction is dispatched.
|
|
253
|
+
|
|
254
|
+
A naïve wrapper handler (`<div onKeyDownCapture={...}>` calling `preventDefault`) does NOT work reliably: ProseMirror's keymap is a plugin inside the editor, which fires its handler in the same event tick as the React capture phase but **commits the HardBreak transaction before React's stopPropagation gets a chance to matter**. The user sees a hard-break flash in, then the next Enter submits — the "first Enter inserts newline" bug we shipped before this extension landed.
|
|
255
|
+
|
|
256
|
+
### Behaviour details
|
|
257
|
+
|
|
258
|
+
| Key | Behaviour |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `Enter` | Calls `onSubmit()`. Returns `true`/`undefined` ⇒ consume. Returns `false` ⇒ fall through to HardBreak. |
|
|
261
|
+
| `Shift+Enter` | Always inserts a newline. Bound to `() => false` so the chain falls through cleanly. |
|
|
262
|
+
| Mention popover open | Enter is given to the suggestion plugin (it picks the active item) — detected by querying `.markdown-mention-list`. |
|
|
263
|
+
| IME composition | Native browser composition events are not intercepted (Tiptap handles them upstream). |
|
|
264
|
+
|
|
265
|
+
The handler is captured via a ref inside `MarkdownEditor`, so swapping `onSubmit` between renders is safe — the latest closure always fires, no editor rebuild needed.
|
|
266
|
+
|
|
267
|
+
## Dependencies
|
|
268
|
+
|
|
269
|
+
All Tiptap packages and `@floating-ui/dom` are direct dependencies — no extra installs needed.
|