@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,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.