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