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