@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,83 @@
1
+ // SubmitOnEnter — Tiptap extension that hooks ProseMirror's keymap
2
+ // before StarterKit's HardBreak so Enter and Cmd/Ctrl+Enter both submit
3
+ // while Shift+Enter inserts a newline (ChatGPT / Telegram behaviour).
4
+ //
5
+ // Why an extension and not a wrapper `onKeyDown` handler:
6
+ //
7
+ // Tiptap registers its keymaps inside the ProseMirror keymap
8
+ // plugin, which runs in the ProseMirror dispatch pipeline. A React
9
+ // `onKeyDown` / `onKeyDownCapture` on a wrapper div fires AFTER
10
+ // ProseMirror has already committed the HardBreak transaction in
11
+ // the same event tick — so calling `preventDefault()` there is too
12
+ // late: the user sees a hard-break flash in, the React handler
13
+ // then runs its preventDefault, and the next Enter submits.
14
+ //
15
+ // Registering the keybinding via `addKeyboardShortcuts` puts us in
16
+ // the same keymap pipeline at a higher priority than StarterKit's
17
+ // default Enter binding, so we intercept before HardBreak runs.
18
+ //
19
+ // The handler also respects IME composition and the mention popover
20
+ // (Tiptap's suggestion plugin captures Enter when its popover is
21
+ // open — we mirror that by checking `.markdown-mention-list` in the
22
+ // DOM; same predicate the wrapper used).
23
+
24
+ import { Extension } from '@tiptap/core';
25
+
26
+ export interface SubmitOnEnterOptions {
27
+ /** Fired when Enter is pressed without Shift, no IME composition,
28
+ * and no mention popover open. Return `true` to consume the key
29
+ * (default behaviour), `false` to let ProseMirror handle it (i.e.
30
+ * fall back to HardBreak). */
31
+ onSubmit: () => boolean | void;
32
+ }
33
+
34
+ export const SubmitOnEnter = Extension.create<SubmitOnEnterOptions>({
35
+ name: 'submitOnEnter',
36
+
37
+ addOptions() {
38
+ return {
39
+ // Default no-op — explicit consumer must override. We never
40
+ // intercept Enter unless an onSubmit is wired, so leaving the
41
+ // extension installed with no handler is safe.
42
+ onSubmit: () => false,
43
+ };
44
+ },
45
+
46
+ addKeyboardShortcuts() {
47
+ // Shared by `Enter` and `Mod-Enter` so the two can never drift into
48
+ // different submit rules.
49
+ const submit = () => {
50
+ // Mention suggestion popover owns Enter while open.
51
+ // The suggestion plugin renders the list with this class
52
+ // (see createMentionSuggestion.ts).
53
+ if (typeof document !== 'undefined' && document.querySelector('.markdown-mention-list')) {
54
+ return false;
55
+ }
56
+ const result = this.options.onSubmit();
57
+ // Default: consume the key (true). Only let it fall through
58
+ // to HardBreak if onSubmit explicitly returned false.
59
+ return result !== false;
60
+ };
61
+
62
+ return {
63
+ Enter: submit,
64
+ // Cmd/Ctrl+Enter submits too. ProseMirror matches a chord EXACTLY, so the
65
+ // `Enter` binding above does NOT fire while a modifier is held — without
66
+ // this line Cmd+Enter fell through to the default and did nothing, in a
67
+ // rich composer only. The plain `<textarea>` path has always sent on it
68
+ // (`useChatComposer`: `!e.shiftKey` is true with Meta down), so the two
69
+ // hosts disagreed about the same keystroke depending on which one the
70
+ // surface happened to mount.
71
+ //
72
+ // Both keys are ONE intent — "send this" — so they share `submit` and go
73
+ // through the composer's single `submit()`, which is what routes to a
74
+ // queued follow-up mid-turn instead of a second send path.
75
+ 'Mod-Enter': submit,
76
+ // Shift+Enter — always insert a newline. Tiptap's StarterKit
77
+ // already binds this to HardBreak; we re-bind to `false` (not
78
+ // handled) so the chain falls through cleanly even if other
79
+ // extensions try to grab Shift+Enter.
80
+ 'Shift-Enter': () => false,
81
+ };
82
+ },
83
+ });
@@ -0,0 +1,50 @@
1
+ /** Item that can be mentioned via @ trigger */
2
+ export interface MentionItem {
3
+ id: string;
4
+ label: string;
5
+ description?: string;
6
+ thumbnail?: string;
7
+ }
8
+
9
+ /**
10
+ * Attributes available when rendering a mention to markdown.
11
+ *
12
+ * Same shape Tiptap stores on the `mention` node — `id` is the stable
13
+ * identifier the suggestion popover injected, `label` is the human text
14
+ * shown to the user. Either field may be empty if the upstream config
15
+ * never populated it; renderers should fall back accordingly.
16
+ */
17
+ export interface MentionAttrs {
18
+ id: string;
19
+ label: string;
20
+ }
21
+
22
+ /**
23
+ * Function that converts a mention node into its markdown serialization.
24
+ *
25
+ * The returned string is what `@tiptap/markdown` writes into the markdown
26
+ * output of `MarkdownEditor.getMarkdown()`.
27
+ */
28
+ export type MentionMarkdownRenderer = (attrs: MentionAttrs) => string;
29
+
30
+ /** Mention configuration */
31
+ export interface MentionConfig {
32
+ /** Trigger character (default: '@') */
33
+ trigger?: string;
34
+ /** Available items */
35
+ items: MentionItem[];
36
+ /** Max dropdown items (default: 5) */
37
+ maxItems?: number;
38
+ /**
39
+ * Custom serializer for mentions when `MarkdownEditor.getMarkdown()` runs.
40
+ *
41
+ * Defaults to `mentionPresets.plainAt` which yields `@<label>` (or `@<id>`
42
+ * when `label` is missing). Use one of `mentionPresets`, or supply your
43
+ * own function for full control over how mentions appear in the output
44
+ * markdown string.
45
+ *
46
+ * Note: this only affects the *markdown* output. `editor.getText()` and
47
+ * the rendered DOM still go through `renderText` (`@<label>` by default).
48
+ */
49
+ renderMarkdown?: MentionMarkdownRenderer;
50
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * URL detection + display helpers for the chat editor's "URL chip" node.
3
+ * Pure, side-effect-free, and unit-testable — no TipTap / ProseMirror /
4
+ * React imports live here so the same functions back the editor node and
5
+ * any standalone use alike.
6
+ *
7
+ * Design goal mirrors `../filePath/detect.ts`: **LOW false-positive**. We
8
+ * only treat a token as a URL when it carries an explicit web scheme
9
+ * (`http`/`https`/`ftp`/`mailto`/`file`) or a bare `www.` host — exactly
10
+ * the set the editor's `shouldAutoLinkUrl` whitelist already accepts, so
11
+ * the chip never disagrees with the autolinker. Bare file paths and dotted
12
+ * filenames (`manifest.md`) are intentionally NOT matched here — those are
13
+ * the file-path node's job.
14
+ */
15
+
16
+ // ── Match result ──
17
+
18
+ export interface UrlMatch {
19
+ /** Inclusive start offset into the scanned string. */
20
+ start: number;
21
+ /** Exclusive end offset into the scanned string. */
22
+ end: number;
23
+ /**
24
+ * The matched URL text, normalised to a navigable href (a bare `www.`
25
+ * host gets an `https://` prefix so the chip's anchor works). Trailing
26
+ * prose punctuation is trimmed.
27
+ */
28
+ href: string;
29
+ /** The raw matched text exactly as it appeared (no scheme added). */
30
+ raw: string;
31
+ }
32
+
33
+ /**
34
+ * Trailing sentence punctuation that, when it hangs off the END of a
35
+ * detected URL token, is almost certainly prose and not part of the URL —
36
+ * trim it. (A URL effectively never ends in these for display purposes;
37
+ * a real trailing `)` that balances an opening `(` is the classic edge
38
+ * case but rare in a chat composer and not worth the complexity.)
39
+ */
40
+ const TRAILING_PUNCT = new Set([
41
+ '.', ',', ')', ']', '}', ';', ':', '!', '?',
42
+ '«', '»', '"', "'", '”', '’', '…',
43
+ ]);
44
+
45
+ /**
46
+ * URL shapes, as a single alternation. Each alternative is whitespace-
47
+ * bounded by construction (`\S` runs). Order: explicit schemes first, then
48
+ * the bare `www.` host.
49
+ *
50
+ * - `mailto:user@host` — mail scheme (no `//`).
51
+ * - `scheme://host/...` — http/https/ftp/file with authority.
52
+ * - `www.host...` — schemeless web host (we prepend https).
53
+ */
54
+ const URL_RE = new RegExp(
55
+ [
56
+ String.raw`mailto:[^\s]+`,
57
+ String.raw`(?:https?|ftp|file):\/\/[^\s]+`,
58
+ String.raw`www\.[^\s/$.?#][^\s]*`,
59
+ ].join('|'),
60
+ 'giu',
61
+ );
62
+
63
+ function trimTrailingPunct(raw: string): string {
64
+ let end = raw.length;
65
+ while (end > 1 && TRAILING_PUNCT.has(raw[end - 1]!)) {
66
+ end -= 1;
67
+ }
68
+ return raw.slice(0, end);
69
+ }
70
+
71
+ /** Normalise a raw token to a navigable href (prepend https for `www.`). */
72
+ export function normalizeHref(raw: string): string {
73
+ if (/^www\./i.test(raw)) return `https://${raw}`;
74
+ return raw;
75
+ }
76
+
77
+ /**
78
+ * Find every URL in `text`. Returns matches in document order with
79
+ * `{ start, end, href, raw }` where `[start, end)` are offsets into the
80
+ * ORIGINAL string (after trailing-punctuation trimming). File paths and
81
+ * bare relative tokens are intentionally NOT matched.
82
+ */
83
+ export function findUrls(text: string): UrlMatch[] {
84
+ if (!text) return [];
85
+
86
+ const out: UrlMatch[] = [];
87
+ URL_RE.lastIndex = 0;
88
+ let m: RegExpExecArray | null;
89
+
90
+ while ((m = URL_RE.exec(text)) !== null) {
91
+ const raw = m[0];
92
+ const start = m.index;
93
+
94
+ if (raw.length === 0) {
95
+ URL_RE.lastIndex += 1;
96
+ continue;
97
+ }
98
+
99
+ // Leading-boundary guard: a real URL token starts the string or
100
+ // follows whitespace / an opening delimiter. Glued onto a preceding
101
+ // non-space char it's a substring of a larger token — reject.
102
+ if (start > 0) {
103
+ const prev = text[start - 1]!;
104
+ if (!/[\s([{<«"'‘“]/.test(prev)) continue;
105
+ }
106
+
107
+ const trimmed = trimTrailingPunct(raw);
108
+ if (trimmed.length < 4) continue;
109
+
110
+ out.push({
111
+ start,
112
+ end: start + trimmed.length,
113
+ href: normalizeHref(trimmed),
114
+ raw: trimmed,
115
+ });
116
+ }
117
+
118
+ return out;
119
+ }
120
+
121
+ // ── Split / label (Chrome-style domain + middle-ellipsis path) ──
122
+
123
+ export interface SplitUrl {
124
+ /** Host without a leading `www.` (e.g. `github.com`). Empty if unparseable. */
125
+ domain: string;
126
+ /** Host for favicon lookup (with `www.` stripped). */
127
+ host: string;
128
+ /** Path + query + hash, leading `/` included (empty for a bare host). */
129
+ rest: string;
130
+ /** `mailto:` address (no scheme) when the URL is a mail link, else ''. */
131
+ mail: string;
132
+ }
133
+
134
+ /**
135
+ * Split an href into `{ domain, host, rest, mail }`. Uses the platform
136
+ * `URL` parser; falls back to a light regex when `URL` throws (it
137
+ * shouldn't for our matched shapes, but stay robust).
138
+ */
139
+ export function splitUrl(href: string): SplitUrl {
140
+ if (/^mailto:/i.test(href)) {
141
+ const mail = href.slice('mailto:'.length);
142
+ const at = mail.lastIndexOf('@');
143
+ const domain = at >= 0 ? mail.slice(at + 1) : '';
144
+ return { domain, host: domain, rest: '', mail };
145
+ }
146
+
147
+ try {
148
+ const u = new URL(normalizeHref(href));
149
+ const host = u.hostname.replace(/^www\./i, '');
150
+ const rest = `${u.pathname === '/' ? '' : u.pathname}${u.search}${u.hash}`;
151
+ return { domain: host, host: u.hostname, rest, mail: '' };
152
+ } catch {
153
+ // Best-effort fallback for an unparseable token.
154
+ const noScheme = normalizeHref(href).replace(/^[a-z]+:\/\//i, '');
155
+ const slash = noScheme.indexOf('/');
156
+ const host = (slash >= 0 ? noScheme.slice(0, slash) : noScheme).replace(/^www\./i, '');
157
+ const rest = slash >= 0 ? noScheme.slice(slash) : '';
158
+ return { domain: host, host, rest, mail: '' };
159
+ }
160
+ }
161
+
162
+ const ELLIPSIS = '…';
163
+
164
+ export interface UrlLabelOptions {
165
+ /**
166
+ * Approximate max characters for the WHOLE label (domain + path). The
167
+ * domain is always kept in full; only the path's middle collapses.
168
+ * Default 40.
169
+ */
170
+ maxChars?: number;
171
+ }
172
+
173
+ /**
174
+ * Produce a compact, Chrome-omnibox-style label for a URL: the domain is
175
+ * sacred (always shown in full), then the path's MIDDLE collapses to `…`
176
+ * while the LAST path segment (often the meaningful slug / filename) is
177
+ * kept.
178
+ *
179
+ * Examples (maxChars ≈ 40):
180
+ * https://github.com/wailsapp/wails/blob/main/v3/README.md
181
+ * → github.com/…/README.md
182
+ * https://github.com/wailsapp/wails
183
+ * → github.com/wailsapp/wails (short — unchanged)
184
+ * mailto:me@example.com → me@example.com
185
+ */
186
+ export function truncateUrlLabel(href: string, opts: UrlLabelOptions = {}): string {
187
+ const maxChars = opts.maxChars ?? 40;
188
+ const { domain, rest, mail } = splitUrl(href);
189
+
190
+ if (mail) {
191
+ return mail.length <= maxChars ? mail : `${ELLIPSIS}${mail.slice(mail.length - (maxChars - 1))}`;
192
+ }
193
+
194
+ const full = `${domain}${rest}`;
195
+ if (full.length <= maxChars) return full;
196
+
197
+ // Collapse the path's middle, keeping the last segment.
198
+ const segments = rest.split('/').filter((s) => s.length > 0);
199
+ if (segments.length === 0) {
200
+ // Only a domain, but it's longer than maxChars — return as-is (CSS
201
+ // ellipsis is the safety net in the chip).
202
+ return domain;
203
+ }
204
+ const last = segments[segments.length - 1]!;
205
+
206
+ const candidates: string[] = [];
207
+ if (segments.length >= 2) {
208
+ candidates.push(`${domain}/${segments[0]!}/${ELLIPSIS}/${last}`);
209
+ }
210
+ candidates.push(`${domain}/${ELLIPSIS}/${last}`);
211
+ candidates.push(`${domain}/${ELLIPSIS}`);
212
+
213
+ for (const c of candidates) {
214
+ if (c.length <= maxChars) return c;
215
+ }
216
+ return `${domain}/${ELLIPSIS}/${last}`;
217
+ }
218
+
219
+ /**
220
+ * Favicon URL for a host via Google's S2 service. Degrades to the globe
221
+ * icon on error (the chip wires an `onerror` handler). Returns '' when the
222
+ * host is empty.
223
+ */
224
+ export function faviconUrl(host: string, size = 32): string {
225
+ if (!host) return '';
226
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=${size}`;
227
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `BookmarkNode` — a NotionEditor block ATOM node that renders a link-preview
3
+ * "bookmark" card (the shared `common/link-preview/LinkPreviewCard`). It holds
4
+ * a single `url` attribute and renders via a React NodeView (mirrors
5
+ * `TaskItemView` → `ReactNodeViewRenderer`).
6
+ *
7
+ * Trigger — paste a BARE URL on an EMPTY line/block:
8
+ *
9
+ * The `handlePaste` plugin only hijacks the paste when the clipboard is a
10
+ * SINGLE bare URL AND the current block is empty (an empty paragraph). In
11
+ * that case it replaces the empty block with a `bookmark` node. A URL
12
+ * pasted mid-text, a labelled link, or multi-line clipboard content all
13
+ * fall through to the editor's normal paste (the URL stays inline). This is
14
+ * the conservative Notion rule — bookmark only on an explicit empty line.
15
+ *
16
+ * Resolver injection:
17
+ *
18
+ * `notionExtensions({ resolveLinkPreview })` threads a host resolver into
19
+ * the node's `options`. The NodeView reads it from `extension.options` and
20
+ * passes it to the card, so the host (Wails/Go, a Next.js API route) unfurls
21
+ * the URL exactly like chat does. Default `undefined` → the card renders its
22
+ * own hostname/favicon fallback.
23
+ *
24
+ * Serialisation (markdown fidelity):
25
+ *
26
+ * `renderMarkdown` emits the bare `url`, so `getMarkdown()` yields the raw
27
+ * URL (NOT card markup) — same pattern the MarkdownEditor chip node uses.
28
+ * On the next `setContent`/paste of a bare URL on an empty line the node is
29
+ * re-created, so the round-trip is stable.
30
+ */
31
+
32
+ import { Node, mergeAttributes } from '@tiptap/core';
33
+ import { ReactNodeViewRenderer } from '@tiptap/react';
34
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
35
+ import type { ResolveLinkPreview } from '../link-preview';
36
+ import { BookmarkView } from './BookmarkView';
37
+
38
+ export interface BookmarkNodeOptions {
39
+ /** Host resolver for URL unfurl. Default `undefined` → card fallback. */
40
+ resolveLinkPreview?: ResolveLinkPreview;
41
+ HTMLAttributes: Record<string, unknown>;
42
+ }
43
+
44
+ declare module '@tiptap/core' {
45
+ interface Commands<ReturnType> {
46
+ bookmark: {
47
+ /** Insert a bookmark (link-preview) block for the given URL. */
48
+ setBookmark: (url: string) => ReturnType;
49
+ };
50
+ }
51
+ }
52
+
53
+ const bookmarkPasteKey = new PluginKey('notion-bookmark-paste');
54
+
55
+ /** A single bare URL (http/https), nothing else on the line. */
56
+ const BARE_URL_RE = /^https?:\/\/[^\s<>]+$/i;
57
+
58
+ /** True when `text` is exactly one bare URL (no surrounding prose). */
59
+ function isBareUrl(text: string): boolean {
60
+ const trimmed = text.trim();
61
+ if (!trimmed || /\s/.test(trimmed)) return false;
62
+ return BARE_URL_RE.test(trimmed);
63
+ }
64
+
65
+ export const BookmarkNode = Node.create<BookmarkNodeOptions>({
66
+ name: 'bookmark',
67
+ group: 'block',
68
+ atom: true,
69
+ draggable: true,
70
+ selectable: true,
71
+
72
+ addOptions() {
73
+ return {
74
+ resolveLinkPreview: undefined,
75
+ HTMLAttributes: {},
76
+ };
77
+ },
78
+
79
+ addAttributes() {
80
+ return {
81
+ url: {
82
+ default: '',
83
+ parseHTML: (el) => el.getAttribute('data-url') ?? '',
84
+ renderHTML: (attrs) => (attrs.url ? { 'data-url': attrs.url as string } : {}),
85
+ },
86
+ };
87
+ },
88
+
89
+ parseHTML() {
90
+ return [{ tag: 'div[data-type="bookmark"]' }];
91
+ },
92
+
93
+ renderHTML({ HTMLAttributes }) {
94
+ return [
95
+ 'div',
96
+ mergeAttributes({ 'data-type': 'bookmark' }, this.options.HTMLAttributes, HTMLAttributes),
97
+ ];
98
+ },
99
+
100
+ addNodeView() {
101
+ return ReactNodeViewRenderer(BookmarkView);
102
+ },
103
+
104
+ addCommands() {
105
+ return {
106
+ setBookmark:
107
+ (url: string) =>
108
+ ({ commands }) =>
109
+ commands.insertContent({ type: this.name, attrs: { url } }),
110
+ };
111
+ },
112
+
113
+ // `@tiptap/markdown` reads this — emit the bare URL so a round-trip through
114
+ // `getMarkdown()` yields the literal URL, never card markup.
115
+ // `attrs` is optional in tiptap's `JSONContent`: a node written by hand or
116
+ // arriving from a paste can carry none, and the narrower annotation this had
117
+ // made the signature incompatible rather than making the object safer.
118
+ renderMarkdown(node: { attrs?: Record<string, unknown> }) {
119
+ return (node.attrs?.url as string) ?? '';
120
+ },
121
+
122
+ addProseMirrorPlugins() {
123
+ const type = this.type;
124
+
125
+ return [
126
+ new Plugin({
127
+ key: bookmarkPasteKey,
128
+ props: {
129
+ // Bare-URL-on-empty-line → bookmark. Conservative: only when the
130
+ // clipboard is a SINGLE bare URL and the current textblock is empty.
131
+ handlePaste: (view, event) => {
132
+ const text = event.clipboardData?.getData('text/plain');
133
+ if (!text || !isBareUrl(text)) return false;
134
+
135
+ const { state } = view;
136
+ const { selection } = state;
137
+ const { $from, empty } = selection;
138
+ // Only replace when the caret sits in an EMPTY textblock with a
139
+ // collapsed selection — i.e. the user is on a fresh empty line.
140
+ if (!empty) return false;
141
+ const parent = $from.parent;
142
+ if (!parent.isTextblock || parent.content.size !== 0) return false;
143
+
144
+ const url = text.trim();
145
+ // Replace the empty block with the bookmark node.
146
+ const node = type.create({ url });
147
+ const tr = state.tr.replaceWith(
148
+ $from.before(),
149
+ $from.after(),
150
+ node,
151
+ );
152
+ view.dispatch(tr.scrollIntoView());
153
+ return true;
154
+ },
155
+ },
156
+ }),
157
+ ];
158
+ },
159
+ });
160
+
161
+ export default BookmarkNode;
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * React NodeView for the NotionEditor `bookmark` node. Renders the shared
5
+ * `LinkPreviewCard` (`appearance="full"`) inside a `NodeViewWrapper`, reading
6
+ * the host resolver injected via the node's `extension.options`
7
+ * (`resolveLinkPreview`). Mirrors `TaskItemView` — we only render the UI; the
8
+ * node owns the `url` attribute + markdown serialisation.
9
+ */
10
+
11
+ import type { ReactNode } from 'react';
12
+ import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
13
+ import { LinkPreviewCard } from '../link-preview';
14
+ import { BlockError, LinkPreviewDataSchema, safeParseBlock } from '../blocks';
15
+ import type { BookmarkNodeOptions } from './BookmarkNode';
16
+
17
+ export function BookmarkView({ node, extension }: NodeViewProps) {
18
+ const url = (node.attrs.url as string) || '';
19
+ const resolver = (extension.options as BookmarkNodeOptions).resolveLinkPreview;
20
+
21
+ // An empty url is a legitimate "nothing to show yet" state (freshly inserted
22
+ // node) — render nothing. A NON-empty but malformed url fails the schema and
23
+ // shows a safe fallback instead of risking a throw downstream.
24
+ const validated = url ? safeParseBlock(LinkPreviewDataSchema, { url }) : null;
25
+
26
+ let content: ReactNode = null;
27
+ if (validated && validated.ok === true) {
28
+ content = <LinkPreviewCard url={validated.data.url} resolver={resolver} appearance="full" />;
29
+ } else if (validated && validated.ok === false) {
30
+ content = <BlockError label="bookmark" reason={validated.error} />;
31
+ }
32
+
33
+ return (
34
+ <NodeViewWrapper
35
+ data-type="bookmark"
36
+ className="notion-bookmark"
37
+ // The drag handle reorders the whole block; keep the card from stealing
38
+ // the drag target by marking the wrapper draggable=false (the node is
39
+ // already `draggable: true` at the schema level for the global handle).
40
+ draggable={false}
41
+ >
42
+ {content}
43
+ </NodeViewWrapper>
44
+ );
45
+ }
@@ -0,0 +1,48 @@
1
+ import { Extension } from '@tiptap/core';
2
+ import { TextSelection } from '@tiptap/pm/state';
3
+
4
+ /**
5
+ * Notion-style smart Cmd/Ctrl+A:
6
+ * 1st press → select the current text block
7
+ * 2nd press → select the whole document
8
+ *
9
+ * Default editor behaviour selects everything on first press, which is
10
+ * jarring in long documents where the user almost always wants "select
11
+ * paragraph" first. Ported from Novel (`extensions/custom-keymap.ts`).
12
+ */
13
+ export const CustomKeymap = Extension.create({
14
+ name: 'notion-custom-keymap',
15
+
16
+ addKeyboardShortcuts() {
17
+ return {
18
+ 'Mod-a': ({ editor }) => {
19
+ const { selection, doc } = editor.state;
20
+ const { $from, $to, from, to } = selection;
21
+
22
+ // Resolve the boundaries of the current text block. `start`/`end`
23
+ // on $from/$to give the inclusive content range of the parent
24
+ // textblock (a paragraph, heading, code block, list item body).
25
+ const blockStart = $from.start();
26
+ const blockEnd = $to.end();
27
+
28
+ const isWholeBlockSelected = from === blockStart && to === blockEnd;
29
+ // If the block is already fully selected → escalate to whole doc.
30
+ if (isWholeBlockSelected) {
31
+ editor
32
+ .chain()
33
+ .setTextSelection({ from: 0, to: doc.content.size })
34
+ .run();
35
+ return true;
36
+ }
37
+
38
+ editor
39
+ .chain()
40
+ .setTextSelection(
41
+ TextSelection.create(editor.state.doc, blockStart, blockEnd),
42
+ )
43
+ .run();
44
+ return true;
45
+ },
46
+ };
47
+ },
48
+ });