@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,121 @@
1
+ import { ReactRenderer } from '@tiptap/react';
2
+ import type { SuggestionOptions } from '@tiptap/suggestion';
3
+ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
4
+ import { SlashList, type SlashListRef } from './SlashList';
5
+ import { filterSlashItems, type SlashItem } from './slashItems';
6
+
7
+ /**
8
+ * `@tiptap/suggestion` config for the slash menu. Mirrors the mention
9
+ * suggestion pattern in `MarkdownEditor/createMentionSuggestion.ts`:
10
+ * floating-ui positioning, virtual element backed by the caret rect,
11
+ * keyboard nav delegated to `SlashList` via an imperative ref.
12
+ *
13
+ * Editor never imports this directly — `notionExtensions()` consumes it.
14
+ */
15
+ export function createSlashSuggestion(): Omit<SuggestionOptions<SlashItem>, 'editor'> {
16
+ return {
17
+ char: '/',
18
+ // Block the menu inside code blocks — `/` is valid syntax there
19
+ // (regex literals, paths) and a floating popover would be noise.
20
+ // Use `editor.isActive('codeBlock')` rather than `state.doc.resolve()`
21
+ // so nested cases (code block in a table cell, in a list item) all
22
+ // resolve correctly — `parent.type.name` only sees the immediate
23
+ // ancestor, which can be the cell/li instead of the code block.
24
+ allow: ({ editor }) => !editor.isActive('codeBlock'),
25
+ items: ({ query }) => filterSlashItems(query),
26
+ command: ({ editor, range, props }) => {
27
+ props.command({ editor, range });
28
+ },
29
+
30
+ render: () => {
31
+ let component: ReactRenderer<SlashListRef> | null = null;
32
+ let popup: HTMLDivElement | null = null;
33
+ let cleanupAutoUpdate: (() => void) | null = null;
34
+ let getReferenceRect: (() => DOMRect | null) | null = null;
35
+
36
+ const buildVirtualElement = () => ({
37
+ getBoundingClientRect: () => getReferenceRect?.() ?? new DOMRect(0, 0, 0, 0),
38
+ });
39
+
40
+ const setPopupVisible = (visible: boolean) => {
41
+ if (popup) popup.style.display = visible ? '' : 'none';
42
+ };
43
+
44
+ const updatePosition = () => {
45
+ if (!popup) return;
46
+ const virtualEl = buildVirtualElement();
47
+ void computePosition(virtualEl, popup, {
48
+ placement: 'bottom-start',
49
+ middleware: [
50
+ offset(6),
51
+ flip({ fallbackPlacements: ['top-start'] }),
52
+ shift({ padding: 8 }),
53
+ ],
54
+ }).then(({ x, y }) => {
55
+ if (!popup) return;
56
+ popup.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
57
+ });
58
+ };
59
+
60
+ const teardown = () => {
61
+ cleanupAutoUpdate?.();
62
+ cleanupAutoUpdate = null;
63
+ popup?.remove();
64
+ popup = null;
65
+ component?.destroy();
66
+ component = null;
67
+ getReferenceRect = null;
68
+ };
69
+
70
+ return {
71
+ onStart: (props) => {
72
+ component = new ReactRenderer(SlashList, {
73
+ props: {
74
+ items: props.items,
75
+ command: (item: SlashItem) => {
76
+ props.command(item);
77
+ },
78
+ },
79
+ editor: props.editor,
80
+ });
81
+
82
+ popup = document.createElement('div');
83
+ popup.style.cssText = 'position: absolute; top: 0; left: 0; z-index: 99999;';
84
+ popup.appendChild(component.element);
85
+ document.body.appendChild(popup);
86
+ setPopupVisible(props.items.length > 0);
87
+
88
+ getReferenceRect = () => props.clientRect?.() ?? null;
89
+ const virtualEl = buildVirtualElement();
90
+ cleanupAutoUpdate = autoUpdate(virtualEl, popup, updatePosition);
91
+ },
92
+
93
+ onUpdate: (props) => {
94
+ component?.updateProps({
95
+ items: props.items,
96
+ command: (item: SlashItem) => {
97
+ props.command(item);
98
+ },
99
+ });
100
+ setPopupVisible(props.items.length > 0);
101
+ getReferenceRect = () => props.clientRect?.() ?? null;
102
+ updatePosition();
103
+ },
104
+
105
+ onKeyDown: (props) => {
106
+ if (props.event.key === 'Escape') {
107
+ teardown();
108
+ return true;
109
+ }
110
+ // `props.event` is a native KeyboardEvent — SlashListRef types
111
+ // it as such, no cast needed.
112
+ return component?.ref?.onKeyDown(props.event) ?? false;
113
+ },
114
+
115
+ onExit: () => {
116
+ teardown();
117
+ },
118
+ };
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,120 @@
1
+ import type { AnyExtension } from '@tiptap/core';
2
+ import { ReactNodeViewRenderer } from '@tiptap/react';
3
+ import StarterKit from '@tiptap/starter-kit';
4
+ import Placeholder from '@tiptap/extension-placeholder';
5
+ import Highlight from '@tiptap/extension-highlight';
6
+ import TaskList from '@tiptap/extension-task-list';
7
+ import TaskItem from '@tiptap/extension-task-item';
8
+ import { Table } from '@tiptap/extension-table';
9
+ import { TableRow } from '@tiptap/extension-table-row';
10
+ import { TableHeader } from '@tiptap/extension-table-header';
11
+ import { TableCell } from '@tiptap/extension-table-cell';
12
+ import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
13
+ import { Markdown } from '@tiptap/markdown';
14
+ import { common, createLowlight } from 'lowlight';
15
+ import GlobalDragHandle from 'tiptap-extension-global-drag-handle';
16
+ import { SlashExtension } from './SlashExtension';
17
+ import { createSlashSuggestion } from './createSlashSuggestion';
18
+ import { TaskItemView } from './TaskItemView';
19
+ import { CustomKeymap } from './CustomKeymap';
20
+ import { BookmarkNode } from './BookmarkNode';
21
+ import { MapNode } from './MapNode';
22
+ import type { ResolveLinkPreview } from '../link-preview';
23
+
24
+ // Lowlight bundle: "common" languages cover ts/js/json/py/go/rust/etc.
25
+ // The full language pack pulls in ~300KB more — opt-in case by case.
26
+ const lowlight = createLowlight(common);
27
+
28
+ export interface BuildExtensionsOptions {
29
+ placeholder: string;
30
+ /**
31
+ * Host resolver for the `bookmark` (link-preview) node. Given a URL it
32
+ * returns unfurl metadata. Threaded into `BookmarkNode.options` and read by
33
+ * its NodeView. Default `undefined` → the card renders its own
34
+ * hostname/favicon fallback (same seam as chat's `config.linkPreview`).
35
+ */
36
+ resolveLinkPreview?: ResolveLinkPreview;
37
+ }
38
+
39
+ /**
40
+ * Assemble the Notion-flavour TipTap extension stack.
41
+ *
42
+ * Why a single factory rather than inline `useEditor({ extensions: [...] })`:
43
+ * - `useEditor` captures extensions once on first render. If we built
44
+ * them inline, every parent re-render would create new instances and
45
+ * React's deps array would force a teardown loop (or, with stable
46
+ * deps, we'd be hiding the problem).
47
+ * - All consumers want the same baseline. Splitting per-extension wires
48
+ * to a builder keeps the assembly readable.
49
+ *
50
+ * StarterKit overrides:
51
+ * - `codeBlock: false` — replaced by `CodeBlockLowlight` for syntax
52
+ * highlighting via lowlight's `common` language pack.
53
+ *
54
+ * Markdown serialisation:
55
+ * - `@tiptap/markdown` already handles starter-kit nodes + tables.
56
+ * - Task lists serialise as GFM `- [x]` / `- [ ]` via the same package.
57
+ * - `Highlight` round-trips as `==text==` (tiptap-markdown default).
58
+ */
59
+ export function notionExtensions(opts: BuildExtensionsOptions): AnyExtension[] {
60
+ return [
61
+ StarterKit.configure({
62
+ heading: { levels: [1, 2, 3, 4] },
63
+ // Disable starter-kit's plain CodeBlock — we replace it with the
64
+ // lowlight variant below for syntax highlighting.
65
+ codeBlock: false,
66
+ }),
67
+ Placeholder.configure({
68
+ // Per-node placeholder: empty H1 → "Heading 1", H2 → "Heading 2",
69
+ // empty paragraph → the supplied placeholder ("Type / for…").
70
+ // Matches Notion: an empty heading is labelled with its level so the
71
+ // user sees structure before content; an empty body block shows the
72
+ // command hint. The function form is supported by tiptap-extensions
73
+ // Placeholder; the `node` arg is the block at the caret.
74
+ placeholder: ({ node }) => {
75
+ if (node.type.name === 'heading') {
76
+ const level = node.attrs.level as number | undefined;
77
+ return level ? `Heading ${level}` : 'Heading';
78
+ }
79
+ return opts.placeholder;
80
+ },
81
+ // includeChildren=true so placeholders also reach inside list items
82
+ // and quotes (otherwise an empty bullet line shows no hint).
83
+ includeChildren: true,
84
+ }),
85
+ Markdown,
86
+ Highlight.configure({ multicolor: false }),
87
+ TaskList,
88
+ // React NodeView swaps Tiptap's default `<input type="checkbox">`
89
+ // for our ui-core <Checkbox> — picks up macOS / token styling and
90
+ // tracks the editor theme automatically. See TaskItemView.tsx.
91
+ TaskItem.extend({
92
+ addNodeView() {
93
+ return ReactNodeViewRenderer(TaskItemView);
94
+ },
95
+ }).configure({ nested: true }),
96
+ Table.configure({ resizable: false }),
97
+ TableRow,
98
+ TableHeader,
99
+ TableCell,
100
+ CodeBlockLowlight.configure({ lowlight }),
101
+ // Drag handle for blocks. Renders a small grabber to the left of the
102
+ // hovered block; clicking + dragging reorders the document. Reads CSS
103
+ // from our styles.css (`.drag-handle`).
104
+ GlobalDragHandle.configure({
105
+ dragHandleWidth: 18,
106
+ // `scrollTreshold` (sic) is the option name the upstream library
107
+ // ships — typo and all. Spelling it correctly is silently ignored.
108
+ scrollTreshold: 100,
109
+ }),
110
+ // Bookmark (link-preview) block — paste a bare URL on an empty line and
111
+ // it becomes a card. The host resolver is threaded through options.
112
+ BookmarkNode.configure({ resolveLinkPreview: opts.resolveLinkPreview }),
113
+ // Map block — inserted via the `/map` slash command (see slashItems).
114
+ MapNode,
115
+ SlashExtension.configure({
116
+ suggestion: createSlashSuggestion(),
117
+ }),
118
+ CustomKeymap,
119
+ ];
120
+ }
@@ -0,0 +1 @@
1
+ export * from './lazy';
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `@djangocfg/widget-editor/notion` subpath entrypoint.
5
+ *
6
+ * `NotionEditor` is a TipTap WYSIWYG with the full Notion-style stack:
7
+ * StarterKit + Markdown + Highlight + Table + TaskList + CodeBlockLowlight
8
+ * + GlobalDragHandle + a custom `/` slash menu. The full bundle including
9
+ * lowlight's `common` language pack lands around ~350KB minified — wrap
10
+ * the component in React.lazy so pages that don't render the editor pay
11
+ * nothing.
12
+ *
13
+ * Why a hand-rolled lazy wrapper (mirrors MarkdownEditor/lazy.tsx):
14
+ * `createLazyComponent` returns a plain function component that does not
15
+ * forward `ref`, so the imperative `NotionEditorHandle` would be silently
16
+ * dropped. The wrapper below is a `forwardRef` so the ref reaches the
17
+ * underlying TipTap editor through `React.lazy`.
18
+ */
19
+
20
+ import { Suspense, forwardRef, lazy } from 'react';
21
+ import { LoadingFallback } from '@djangocfg/widget-kit/lazy';
22
+ import type { NotionEditorHandle, NotionEditorProps } from './types';
23
+
24
+ const NotionEditorImpl = lazy(() =>
25
+ import('./NotionEditor').then((m) => ({ default: m.NotionEditor })),
26
+ );
27
+
28
+ export const LazyNotionEditor = forwardRef<NotionEditorHandle, NotionEditorProps>(
29
+ function LazyNotionEditor(props, ref) {
30
+ return (
31
+ <Suspense fallback={<LoadingFallback minHeight={240} text="Loading editor…" />}>
32
+ <NotionEditorImpl {...props} ref={ref} />
33
+ </Suspense>
34
+ );
35
+ },
36
+ );
37
+
38
+ // `NotionEditor` is the public eager-looking name — it resolves to the
39
+ // same lazy component so importing it from this subpath does NOT pull
40
+ // ~350KB of TipTap + lowlight into the caller's initial bundle.
41
+ export { LazyNotionEditor as NotionEditor };
42
+
43
+ export type { NotionEditorHandle, NotionEditorProps } from './types';
44
+ export type { SlashItem } from './slashItems';
@@ -0,0 +1,293 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import {
4
+ NotionEditor,
5
+ type NotionEditorHandle,
6
+ } from './lazy';
7
+ import type { LinkPreviewData, ResolveLinkPreview } from '../link-preview';
8
+
9
+ const SAMPLE = `# Welcome to NotionEditor
10
+
11
+ A **Notion-style** WYSIWYG editor backed by *TipTap v3* with a curated extension stack.
12
+
13
+ ## Try these
14
+
15
+ - Type \`/\` on an empty line to open the slash menu.
16
+ - Select any text — a **bubble toolbar** appears (bold / italic / strike / code / highlight).
17
+ - Hover a block — the drag handle (⠿) shows up on the left so you can reorder.
18
+
19
+ ### Lists
20
+
21
+ 1. Numbered lists work
22
+ 2. With auto-incrementing markers
23
+ 3. And nested items
24
+
25
+ - Bullet lists too
26
+ - With **inline formatting**
27
+ - And \`inline code\`
28
+
29
+ ### Tasks
30
+
31
+ - [ ] A pending task
32
+ - [x] A completed one
33
+ - [ ] Mix and match
34
+
35
+ ### Quote
36
+
37
+ > Knowledge is power, but enthusiasm pulls the switch.
38
+ > — Ivern Ball
39
+
40
+ ### Code
41
+
42
+ \`\`\`ts
43
+ function greet(name: string): string {
44
+ return \`Hello, \${name}!\`;
45
+ }
46
+ \`\`\`
47
+
48
+ ### Table
49
+
50
+ | Feature | Status |
51
+ | ------------- | -------- |
52
+ | Slash menu | ✓ ready |
53
+ | Bubble menu | ✓ ready |
54
+ | Drag handle | ✓ ready |
55
+ | Tables | ✓ ready |
56
+ | Code blocks | ✓ ready |
57
+
58
+ ---
59
+
60
+ The editor serialises everything back to **GitHub-Flavoured Markdown** on every change.
61
+ `;
62
+
63
+ function Host(props: {
64
+ initial?: string;
65
+ disabled?: boolean;
66
+ autoFocus?: boolean;
67
+ minHeight?: number;
68
+ placeholder?: string;
69
+ showOutput?: boolean;
70
+ resolveLinkPreview?: ResolveLinkPreview;
71
+ /** When set, seed a bookmark card for this URL once the editor mounts. */
72
+ seedBookmark?: string;
73
+ }) {
74
+ const [value, setValue] = useState(props.initial ?? '');
75
+ const ref = useRef<NotionEditorHandle>(null);
76
+
77
+ // Seed a bookmark via the imperative editor command once it's ready. This
78
+ // demonstrates the bookmark node without relying on a real paste — the same
79
+ // node a bare-URL paste on an empty line produces.
80
+ const seed = props.seedBookmark;
81
+ useEffect(() => {
82
+ if (!seed) return;
83
+ let tries = 0;
84
+ const id = window.setInterval(() => {
85
+ const editor = ref.current?.getEditor();
86
+ tries += 1;
87
+ if (editor) {
88
+ editor.chain().focus().setBookmark(seed).run();
89
+ window.clearInterval(id);
90
+ } else if (tries > 40) {
91
+ window.clearInterval(id);
92
+ }
93
+ }, 50);
94
+ return () => window.clearInterval(id);
95
+ }, [seed]);
96
+
97
+ return (
98
+ <div className="grid w-full max-w-[1400px] grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
99
+ <div className="rounded-lg border border-border bg-card p-4">
100
+ <NotionEditor
101
+ ref={ref}
102
+ value={value}
103
+ onChange={setValue}
104
+ disabled={props.disabled}
105
+ autoFocus={props.autoFocus}
106
+ minHeight={props.minHeight ?? 420}
107
+ placeholder={props.placeholder}
108
+ resolveLinkPreview={props.resolveLinkPreview}
109
+ onSave={(md) => console.log('[onSave]', md)}
110
+ />
111
+ </div>
112
+ {props.showOutput !== false ? (
113
+ <div className="rounded-lg border border-border bg-muted/30 p-3">
114
+ <div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
115
+ Serialised markdown
116
+ </div>
117
+ <pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words text-[12px] leading-relaxed font-mono">
118
+ {value || '(empty)'}
119
+ </pre>
120
+ </div>
121
+ ) : null}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ // ── A fake link-preview resolver (stands in for the host's Wails/Go or
127
+ // Next.js /api/unfurl fetcher), mirroring the chat LinkPreview story. ──
128
+ const LINK_FIXTURES: Record<string, LinkPreviewData> = {
129
+ 'https://github.com/wailsapp/wails': {
130
+ url: 'https://github.com/wailsapp/wails',
131
+ title: 'wailsapp/wails: Create beautiful applications using Go',
132
+ description:
133
+ 'Build desktop applications using Go & Web Technologies. Wails bundles a ' +
134
+ 'Go binary with a native webview front-end.',
135
+ image: 'https://picsum.photos/seed/wails-og/640/320',
136
+ favicon: 'https://www.google.com/s2/favicons?domain=github.com&sz=64',
137
+ siteName: 'GitHub',
138
+ },
139
+ 'https://www.notion.so': {
140
+ url: 'https://www.notion.so',
141
+ title: 'Notion – The all-in-one workspace',
142
+ description: 'Write, plan, share. With AI built in. Notion is the connected workspace.',
143
+ favicon: 'https://www.google.com/s2/favicons?domain=notion.so&sz=64',
144
+ siteName: 'Notion',
145
+ },
146
+ };
147
+
148
+ /** Mock resolver — 500ms latency to show the card skeleton, then the fixture. */
149
+ const mockResolveLinkPreview: ResolveLinkPreview = (url) =>
150
+ new Promise((resolve) => {
151
+ setTimeout(() => resolve(LINK_FIXTURES[url] ?? { url }), 500);
152
+ });
153
+
154
+ // An initial document that already contains a `/map` block (a fenced ```map
155
+ // JSON block). The MapNode's markdown tokenizer parses it back into a live
156
+ // map on load — proving the round-trip.
157
+ const MAP_DOC = `# Trip plan
158
+
159
+ Drop a map anywhere with **/map** — or paste a fenced \`map\` block:
160
+
161
+ \`\`\`map
162
+ {
163
+ "center": { "lat": 48.8584, "lng": 2.2945 },
164
+ "zoom": 13,
165
+ "markers": [
166
+ { "id": "m1", "lat": 48.8584, "lng": 2.2945, "label": "Eiffel Tower" },
167
+ { "id": "m2", "lat": 48.8606, "lng": 2.3376, "label": "Louvre" }
168
+ ]
169
+ }
170
+ \`\`\`
171
+
172
+ The map serialises back to the same fenced block on every change.
173
+ `;
174
+
175
+ const meta = {
176
+ title: 'Widgets/Editor/NotionEditor',
177
+ component: Host,
178
+ tags: ['autodocs'],
179
+ parameters: {
180
+ layout: 'padded',
181
+ docs: {
182
+ description: {
183
+ component:
184
+ 'Notion-style WYSIWYG markdown editor built on TipTap v3. Type `/` for the slash menu, select text for the bubble menu, hover any block to reveal the drag handle on the left.',
185
+ },
186
+ },
187
+ },
188
+ } satisfies Meta<typeof Host>;
189
+
190
+ export default meta;
191
+
192
+ type Story = StoryObj<typeof meta>;
193
+
194
+ export const Default: Story = {
195
+ name: 'Default (empty)',
196
+ render: () => <Host showOutput />,
197
+ };
198
+
199
+ export const WithContent: Story = {
200
+ name: 'With sample content',
201
+ render: () => <Host initial={SAMPLE} showOutput />,
202
+ };
203
+
204
+ export const AutoFocus: Story = {
205
+ name: 'Auto-focus on mount',
206
+ render: () => (
207
+ <Host initial="" autoFocus placeholder="Type / to open the slash menu…" showOutput />
208
+ ),
209
+ };
210
+
211
+ export const ReadOnly: Story = {
212
+ name: 'Read-only',
213
+ render: () => <Host initial={SAMPLE} disabled showOutput={false} />,
214
+ };
215
+
216
+ export const Compact: Story = {
217
+ name: 'Compact (240px min-height)',
218
+ render: () => <Host minHeight={240} showOutput />,
219
+ };
220
+
221
+ /**
222
+ * Bookmark (link-preview) block. Paste a BARE URL on an EMPTY line and it
223
+ * becomes a link-preview card (Notion behaviour). A labelled link or a URL
224
+ * pasted mid-text stays an inline link. The `resolveLinkPreview` host resolver
225
+ * unfurls the URL into title/description/image; the node serialises back to
226
+ * the raw URL (see the markdown panel). This story pre-seeds one card on
227
+ * mount; try pasting `https://www.notion.so` on a fresh empty line too.
228
+ */
229
+ export const Bookmark: Story = {
230
+ name: 'Bookmark (paste a URL → card)',
231
+ render: () => (
232
+ <Host
233
+ seedBookmark="https://github.com/wailsapp/wails"
234
+ resolveLinkPreview={mockResolveLinkPreview}
235
+ placeholder="Paste a bare URL on an empty line…"
236
+ showOutput
237
+ />
238
+ ),
239
+ };
240
+
241
+ /**
242
+ * Map block. Insert one via the slash menu (`/map`) — it drops a live map
243
+ * seeded with a default center + marker. This story loads a document that
244
+ * already contains a fenced ```` ```map ```` JSON block, which the MapNode's
245
+ * markdown tokenizer parses into a live map on mount (round-trip proof).
246
+ */
247
+ export const Map: Story = {
248
+ name: 'Map (/map → map block)',
249
+ render: () => <Host initial={MAP_DOC} showOutput />,
250
+ };
251
+
252
+ // A document with a DELIBERATELY BROKEN ```map block — malformed JSON (a
253
+ // trailing comma + a non-numeric coordinate). The MapNode tokenizer parses it
254
+ // without throwing and hands the node attrs that FAIL the schema, so the
255
+ // NodeView renders a `<BlockError>` card. The editor stays fully alive — type
256
+ // below the broken block to prove it. This is the "don't let a corrupted
257
+ // payload take down the editor" guarantee.
258
+ const BROKEN_MAP_DOC = `# Broken block recovery
259
+
260
+ This map block has corrupted JSON — it renders a safe error card, **not** a crash:
261
+
262
+ \`\`\`map
263
+ {
264
+ "center": { "lat": "not-a-number", "lng": 2.2945 },
265
+ "zoom": 13,
266
+ }
267
+ \`\`\`
268
+
269
+ The editor is still fully usable — try typing here. ✏️
270
+ `;
271
+
272
+ /**
273
+ * Broken payload → `<BlockError>`. A malformed ```` ```map ```` JSON (a
274
+ * non-numeric latitude) fails the shared Zod schema; the NodeView renders a
275
+ * compact "Couldn't render this map" card instead of throwing. The editor
276
+ * keeps working. Proves the validator safety net (phase 2).
277
+ */
278
+ export const BrokenMap: Story = {
279
+ name: 'Broken map block → BlockError',
280
+ render: () => <Host initial={BROKEN_MAP_DOC} showOutput />,
281
+ };
282
+
283
+ /**
284
+ * Editable map — drag a pin and watch the serialised ```` ```map ```` JSON in
285
+ * the markdown panel update to the new coordinates. Use the on-map controls
286
+ * (top-left) to ADD a pin (click the map to place) or REMOVE pins, and the
287
+ * Layers chip (top-right) to switch the basemap — each writes back into the
288
+ * markdown. This is the headline interaction (phase 3).
289
+ */
290
+ export const EditableMap: Story = {
291
+ name: 'Editable map (drag → markdown updates)',
292
+ render: () => <Host initial={MAP_DOC} showOutput />,
293
+ };