@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,526 @@
1
+ /**
2
+ * `ChipNode` — a single TipTap inline ATOM node that renders BOTH file-path
3
+ * chips and URL chips, distinguished by a `kind` attribute. Replaces the old
4
+ * `FilePathChipExtension` decoration approach.
5
+ *
6
+ * Why a node (not a decoration), and why VSCode-style middle-ellipsis:
7
+ *
8
+ * The previous decoration was an OVERLAY — it could add a class + icon
9
+ * widget over the raw path text, but it could NOT replace the visible
10
+ * text. A long path therefore rendered in FULL and wrapped to several
11
+ * lines. An atomic inline node OWNS its rendering, so it can show a
12
+ * middle-ellipsised label (`…/dev/Map/index.ts`) on a single line while
13
+ * the FULL path lives only in the node's `path` attribute (and in copy /
14
+ * markdown output). This mirrors how `@tiptap/extension-mention` and the
15
+ * `SlashCommandNode` are atomic inline nodes that flatten back to plain
16
+ * text on serialise.
17
+ *
18
+ * Two kinds, one node (DRY):
19
+ *
20
+ * - `kind: 'path'` — attr `path`. Icon = `getFileIcon(base).svg` (folder
21
+ * glyph when `isDir`). Label = `truncatePathLabel(path)`. Serialises to
22
+ * the RAW `path` string.
23
+ * - `kind: 'url'` — attr `href`. Icon = a favicon (Google S2) that
24
+ * degrades to a globe glyph on error. Label = domain + middle-ellipsis
25
+ * of the path (`github.com/…/README.md`). Clickable (opens in a new
26
+ * tab). Serialises to the RAW `href` string.
27
+ *
28
+ * Serialisation (the whole point — text/markdown fidelity):
29
+ *
30
+ * `renderText` and `renderMarkdown` BOTH emit the literal raw path/href,
31
+ * so `editor.getText()`, copy, the submitted message, and `getMarkdown()`
32
+ * all contain the real string — NEVER chip markup. URLs serialise as a
33
+ * bare URL (the editor's autolinker re-links a bare URL on the next
34
+ * `setContent`, matching how the editor already treats pasted URLs).
35
+ *
36
+ * Auto-conversion:
37
+ *
38
+ * An input rule fires when the user types a path/URL followed by a
39
+ * boundary (space / newline / punctuation) and replaces the matched range
40
+ * with a chip node. A paste rule does the same for pasted text. Both reuse
41
+ * the pure detectors (`findFilePaths`, `findUrls`) so detection never
42
+ * drifts from the standalone chip / autolinker.
43
+ *
44
+ * The node renders via a DOM `addNodeView` (no React mount) — robust, cheap,
45
+ * and consistent with the React-free icon the old decoration used.
46
+ */
47
+
48
+ import { Node, mergeAttributes, InputRule } from '@tiptap/core';
49
+ import type { JSONContent, MarkdownRendererHelpers, RenderContext } from '@tiptap/core';
50
+ import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
51
+ import { Fragment, Slice, type Node as PMNode } from '@tiptap/pm/model';
52
+ import { findFilePaths, splitPath, truncatePathLabel } from '../filePath/detect';
53
+ import { findUrls, splitUrl, truncateUrlLabel, faviconUrl } from '../url/detect';
54
+ import { getFileIcon } from '@djangocfg/widget-data/file-icon/get-file-icon';
55
+
56
+ export type ChipKind = 'path' | 'url';
57
+
58
+ export interface ChipNodeOptions {
59
+ /** Enable auto-conversion of typed/pasted file paths into `path` chips. */
60
+ enablePaths: boolean;
61
+ /** Enable auto-conversion of typed/pasted URLs into `url` chips. */
62
+ enableUrls: boolean;
63
+ HTMLAttributes: Record<string, unknown>;
64
+ }
65
+
66
+ const chipPasteKey = new PluginKey('editorChipPaste');
67
+
68
+ const FOLDER_SVG =
69
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" ' +
70
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
71
+ 'stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2' +
72
+ 'h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 ' +
73
+ '2 0 0 0 2 2Z"/></svg>';
74
+
75
+ const GLOBE_SVG =
76
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" ' +
77
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
78
+ 'stroke-linejoin="round"><circle cx="12" cy="12" r="10"/>' +
79
+ '<path d="M2 12h20"/>' +
80
+ '<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
81
+
82
+ /** Build the leading icon element for a path chip. */
83
+ function buildPathIcon(path: string): HTMLElement {
84
+ const { base, isDir } = splitPath(path);
85
+ const span = document.createElement('span');
86
+ span.className = 'editor-chip__icon';
87
+ span.setAttribute('aria-hidden', 'true');
88
+ if (isDir) {
89
+ span.classList.add('editor-chip__icon--folder');
90
+ span.innerHTML = FOLDER_SVG;
91
+ } else {
92
+ span.innerHTML = getFileIcon(base).svg;
93
+ }
94
+ return span;
95
+ }
96
+
97
+ /** Build the leading icon element for a URL chip (favicon → globe fallback). */
98
+ function buildUrlIcon(href: string): HTMLElement {
99
+ const span = document.createElement('span');
100
+ span.className = 'editor-chip__icon editor-chip__icon--url';
101
+ span.setAttribute('aria-hidden', 'true');
102
+
103
+ const { host } = splitUrl(href);
104
+ const favicon = faviconUrl(host);
105
+ if (favicon) {
106
+ const img = document.createElement('img');
107
+ img.src = favicon;
108
+ img.alt = '';
109
+ img.width = 14;
110
+ img.height = 14;
111
+ img.className = 'editor-chip__favicon';
112
+ // Degrade to the globe glyph if the favicon fails (offline / blocked /
113
+ // unknown host) so the chip never shows a broken-image marker.
114
+ img.addEventListener('error', () => {
115
+ span.innerHTML = GLOBE_SVG;
116
+ span.classList.add('editor-chip__icon--globe');
117
+ });
118
+ span.appendChild(img);
119
+ } else {
120
+ span.innerHTML = GLOBE_SVG;
121
+ span.classList.add('editor-chip__icon--globe');
122
+ }
123
+ return span;
124
+ }
125
+
126
+ /** Build the full chip DOM for a node's attrs. */
127
+ function buildChipDom(kind: ChipKind, path: string, href: string): HTMLElement {
128
+ const dom = document.createElement(kind === 'url' ? 'a' : 'span');
129
+ dom.className = `editor-chip editor-chip--${kind}`;
130
+ dom.setAttribute('data-type', 'editorChip');
131
+ dom.setAttribute('data-kind', kind);
132
+ dom.setAttribute('contenteditable', 'false');
133
+
134
+ const labelSpan = document.createElement('span');
135
+ labelSpan.className = 'editor-chip__label';
136
+
137
+ if (kind === 'url') {
138
+ const a = dom as HTMLAnchorElement;
139
+ a.href = href;
140
+ a.target = '_blank';
141
+ a.rel = 'noopener noreferrer';
142
+ a.title = href;
143
+ a.setAttribute('data-href', href);
144
+ a.appendChild(buildUrlIcon(href));
145
+ labelSpan.textContent = truncateUrlLabel(href);
146
+ } else {
147
+ dom.title = path;
148
+ dom.setAttribute('data-path', path);
149
+ dom.appendChild(buildPathIcon(path));
150
+ labelSpan.textContent = truncatePathLabel(path);
151
+ }
152
+
153
+ dom.appendChild(labelSpan);
154
+ return dom;
155
+ }
156
+
157
+ /** Raw string a chip serialises to (text / markdown). */
158
+ function chipRawText(attrs: { kind?: ChipKind; path?: string | null; href?: string | null }): string {
159
+ if (attrs.kind === 'url') return attrs.href ?? '';
160
+ return attrs.path ?? '';
161
+ }
162
+
163
+ export const ChipNode = Node.create<ChipNodeOptions>({
164
+ name: 'editorChip',
165
+ group: 'inline',
166
+ inline: true,
167
+ atom: true,
168
+ selectable: true,
169
+ // Above plain text so paste rules don't try to re-parse our chip.
170
+ priority: 101,
171
+
172
+ addOptions() {
173
+ return {
174
+ enablePaths: true,
175
+ enableUrls: true,
176
+ HTMLAttributes: {},
177
+ };
178
+ },
179
+
180
+ addAttributes() {
181
+ return {
182
+ kind: {
183
+ default: 'path',
184
+ parseHTML: (el) => el.getAttribute('data-kind') ?? 'path',
185
+ renderHTML: (attrs) => ({ 'data-kind': (attrs.kind as string) || 'path' }),
186
+ },
187
+ path: {
188
+ default: null,
189
+ parseHTML: (el) => el.getAttribute('data-path'),
190
+ renderHTML: (attrs) => (attrs.path ? { 'data-path': attrs.path as string } : {}),
191
+ },
192
+ href: {
193
+ default: null,
194
+ parseHTML: (el) => el.getAttribute('data-href'),
195
+ renderHTML: (attrs) => (attrs.href ? { 'data-href': attrs.href as string } : {}),
196
+ },
197
+ };
198
+ },
199
+
200
+ parseHTML() {
201
+ return [{ tag: 'a[data-type="editorChip"]' }, { tag: 'span[data-type="editorChip"]' }];
202
+ },
203
+
204
+ renderHTML({ node, HTMLAttributes }) {
205
+ const kind = (node.attrs.kind as ChipKind) || 'path';
206
+ const tag = kind === 'url' ? 'a' : 'span';
207
+ const extra: Record<string, unknown> = {
208
+ 'data-type': 'editorChip',
209
+ class: `editor-chip editor-chip--${kind}`,
210
+ };
211
+ if (kind === 'url' && node.attrs.href) {
212
+ extra.href = node.attrs.href;
213
+ extra.target = '_blank';
214
+ extra.rel = 'noopener noreferrer';
215
+ }
216
+ return [
217
+ tag,
218
+ mergeAttributes(extra, this.options.HTMLAttributes, HTMLAttributes),
219
+ chipRawText(node.attrs as { kind?: ChipKind; path?: string | null; href?: string | null }),
220
+ ];
221
+ },
222
+
223
+ // DOM nodeview — renders the icon + middle-ellipsis label and owns the
224
+ // visible text (that's what fixes the long-path wrap).
225
+ addNodeView() {
226
+ return ({ node }) => {
227
+ const kind = (node.attrs.kind as ChipKind) || 'path';
228
+ const dom = buildChipDom(
229
+ kind,
230
+ (node.attrs.path as string | null) ?? '',
231
+ (node.attrs.href as string | null) ?? '',
232
+ );
233
+ return { dom };
234
+ };
235
+ },
236
+
237
+ // `editor.getText()` + markdown text fallback read this — the chip
238
+ // flattens to its raw path/href.
239
+ renderText({ node }) {
240
+ return chipRawText(node.attrs as { kind?: ChipKind; path?: string | null; href?: string | null });
241
+ },
242
+
243
+ // `@tiptap/markdown` reads this at registration time. Emit the bare raw
244
+ // string (NOT a shortcode), so a round-trip through `getMarkdown()`
245
+ // yields the literal path / URL.
246
+ renderMarkdown(node: JSONContent, _helpers: MarkdownRendererHelpers, _ctx: RenderContext) {
247
+ return chipRawText(node.attrs as { kind?: ChipKind; path?: string | null; href?: string | null });
248
+ },
249
+
250
+ addInputRules() {
251
+ const type = this.type;
252
+ const rules: InputRule[] = [];
253
+
254
+ // The input rule fires when WHITESPACE is typed right after a path/URL
255
+ // token. We detect on the token BEFORE the space and replace that slice
256
+ // (keeping the space) with a chip node.
257
+ //
258
+ // ── Why we DON'T trust `match` / `range` from TipTap's `find` ──
259
+ //
260
+ // TipTap matches `rule.find` against `getTextContentFromNodes($from)`,
261
+ // which — for an ATOM node like our chip — appends the node's *full*
262
+ // `renderText` output (the raw URL/path string) to the text-before. So
263
+ // once a chip exists, the text-before the cursor is e.g.
264
+ // `http://localhost:6017/x` + whatever the user just typed, glued with
265
+ // NO separator (the chip serialises without a trailing space).
266
+ //
267
+ // That breaks the rule two ways:
268
+ // 1) `/(\S+)(\s)$/` re-matches the chip text fused to the next word
269
+ // (`http://localhost:6017/xпривет`) — still a valid URL — so the
270
+ // rule RE-FIRES on every word typed after a chip.
271
+ // 2) `range.from = from - (match[0].length - text.length)` assumes
272
+ // each char of `match[0]` is one doc position, but the chip is a
273
+ // single atom position contributing ~20 chars. So `range.from`
274
+ // lands far to the LEFT of the real token and the replace range
275
+ // swallows the typed space (the reported `приветкакделаутебя` bug).
276
+ //
277
+ // Fix: ignore `match`/`range` entirely and recompute the token from the
278
+ // REAL document — the contiguous text node that ends at the cursor.
279
+ // That text node starts AFTER any preceding chip (chips are separate
280
+ // sibling nodes), so chip text can never leak into the token.
281
+ //
282
+ // The boundary is WHITESPACE ONLY — deliberately NOT punctuation. Paths
283
+ // and URLs legitimately contain `.` `/` `:` `-` etc., so a punctuation
284
+ // boundary would fire mid-token (e.g. converting `…/Map/index` the
285
+ // instant the `.` of `.ts` is typed). Punctuation-terminated tokens are
286
+ // still handled on paste / `setContent` by the detectors' trailing-punct
287
+ // trim; live typing only chips on a real word boundary.
288
+ const makeRule = (
289
+ detect: (token: string) => { start: number; end: number; attrs: Record<string, unknown> } | null,
290
+ ) =>
291
+ new InputRule({
292
+ find: /(\S+)(\s)$/u,
293
+ handler: ({ range, match, chain, state }) => {
294
+ // The boundary whitespace the user just typed. CRUCIAL: TipTap's
295
+ // `handleTextInput` fires BEFORE the char is inserted, so this
296
+ // boundary is NOT yet in the document — it lives only in `match`.
297
+ // We must therefore re-insert it ourselves below.
298
+ const boundary = match[2] ?? '';
299
+ // `range.to` is the real cursor (insertion point). Unlike `match`
300
+ // it is a genuine doc position untouched by atom-text pollution.
301
+ const cursor = range.to;
302
+ const $cursor = state.doc.resolve(cursor);
303
+
304
+ // The text node immediately before the cursor (the run that holds
305
+ // the typed token). If the char before the cursor isn't text —
306
+ // e.g. the cursor sits right after a chip — there's nothing to
307
+ // convert. This text node starts AFTER any preceding chip (chips
308
+ // are separate sibling nodes), so chip text can never leak in.
309
+ const before = $cursor.nodeBefore;
310
+ if (!before || !before.isText || !before.text) return null;
311
+
312
+ const textNodeText = before.text;
313
+ const textNodeStart = cursor - textNodeText.length;
314
+
315
+ // Take the contiguous non-whitespace run at the END of the text
316
+ // node — that's the token candidate (the boundary char is not in
317
+ // the doc yet, so the node tail IS the token).
318
+ const lastWsIdx = textNodeText.search(/\s\S*$/u);
319
+ const tokenStartInText = lastWsIdx === -1 ? 0 : lastWsIdx + 1;
320
+ const token = textNodeText.slice(tokenStartInText);
321
+ if (!token) return null;
322
+
323
+ const found = detect(token);
324
+ if (!found) return null;
325
+
326
+ // Map the detector's offsets (relative to `token`) to absolute doc
327
+ // positions.
328
+ const tokenStart = textNodeStart + tokenStartInText;
329
+ const sliceFrom = tokenStart + found.start;
330
+ const sliceTo = tokenStart + found.end;
331
+ // Any token chars the detector trimmed off the end (e.g. trailing
332
+ // prose punctuation) stay as text, then the boundary char.
333
+ const tail = token.slice(found.end) + boundary;
334
+
335
+ chain()
336
+ // Replace [chip-slice .. cursor) with the chip node followed by
337
+ // the leftover tail + boundary char. Doing both in one
338
+ // `insertContentAt` keeps positions simple: the boundary space is
339
+ // a real text node AFTER the atom, giving the caret a valid
340
+ // landing spot (an atom at the very end of a textblock otherwise
341
+ // traps the caret and swallows subsequently typed spaces).
342
+ .insertContentAt({ from: sliceFrom, to: cursor }, [
343
+ { type: type.name, attrs: found.attrs },
344
+ { type: 'text', text: tail },
345
+ ])
346
+ // Land the caret at the END of the inserted content (after the
347
+ // boundary space) so typing continues as plain text.
348
+ .command(({ tr, dispatch }) => {
349
+ if (dispatch) {
350
+ // chip = 1 pos, then `tail` chars. Caret sits past them.
351
+ const pos = Math.min(sliceFrom + 1 + tail.length, tr.doc.content.size);
352
+ tr.setSelection(TextSelection.create(tr.doc, pos));
353
+ }
354
+ return true;
355
+ })
356
+ .run();
357
+ return;
358
+ },
359
+ });
360
+
361
+ if (this.options.enablePaths) {
362
+ rules.push(
363
+ makeRule((token) => {
364
+ const m = findFilePaths(token);
365
+ if (m.length === 0) return null;
366
+ const hit = m[0]!;
367
+ return { start: hit.start, end: hit.end, attrs: { kind: 'path', path: hit.path } };
368
+ }),
369
+ );
370
+ }
371
+ if (this.options.enableUrls) {
372
+ rules.push(
373
+ makeRule((token) => {
374
+ const m = findUrls(token);
375
+ if (m.length === 0) return null;
376
+ const hit = m[0]!;
377
+ return { start: hit.start, end: hit.end, attrs: { kind: 'url', href: hit.href } };
378
+ }),
379
+ );
380
+ }
381
+
382
+ return rules;
383
+ },
384
+
385
+ addProseMirrorPlugins() {
386
+ const enablePaths = this.options.enablePaths;
387
+ const enableUrls = this.options.enableUrls;
388
+ const type = this.type;
389
+
390
+ return [
391
+ new Plugin({
392
+ key: chipPasteKey,
393
+ props: {
394
+ // Paste the TEXT that was copied — with detected paths/URLs turned
395
+ // into chips — never the styling the clipboard carried alongside it.
396
+ //
397
+ // ProseMirror prefers `text/html` over `text/plain`, and a terminal
398
+ // puts colour-span HTML on the clipboard: those spans arrived as real
399
+ // marks, and the HTML branch parses with `preserveWhitespace: false`,
400
+ // collapsing the indentation a `tree` listing is made of.
401
+ //
402
+ // So this owns EVERY plain-text paste, not just one containing a
403
+ // path. Bailing out on "no hits" handed the rest back to the HTML
404
+ // branch, which is the mangling one.
405
+ //
406
+ // Not fixable via `transformPastedHTML`/`clipboardTextParser`:
407
+ // `asText` is decided before either runs, so blanking the HTML there
408
+ // pastes nothing at all.
409
+ handlePaste: (view, event) => {
410
+ const text = event.clipboardData?.getData('text/plain');
411
+ if (!text) return false;
412
+ // Inside a code block PM already pastes verbatim text and a chip
413
+ // would be nonsense there — leave its own handling alone.
414
+ if (view.state.selection.$from.parent.type.spec.code) return false;
415
+
416
+ const { schema } = view.state;
417
+ // One paragraph per LINE: a newline cannot live inside a PM text
418
+ // node, so the previous single-slice build flattened a multi-line
419
+ // paste into one run-on line — and a `tree` dump is exactly the
420
+ // text most likely to contain a path.
421
+ const paragraphs = chipPasteLines(text, enablePaths, enableUrls).map(
422
+ (line) =>
423
+ schema.nodes.paragraph!.create(
424
+ null,
425
+ line.map((part) =>
426
+ 'chip' in part ? type.create(part.chip) : schema.text(part.text),
427
+ ),
428
+ ),
429
+ );
430
+
431
+ const { from, to } = view.state.selection;
432
+ // A single paragraph stays an INLINE slice (open on both sides) so
433
+ // it merges into the line the caret is on — pasting one path into
434
+ // the middle of a sentence must not split the sentence. Several
435
+ // paragraphs are a block slice, which is what preserves the lines.
436
+ const slice =
437
+ paragraphs.length === 1
438
+ ? new Slice(paragraphs[0]!.content, 0, 0)
439
+ : new Slice(Fragment.fromArray(paragraphs), 1, 1);
440
+ const tr = view.state.tr.replaceRange(from, to, slice);
441
+ view.dispatch(tr.scrollIntoView());
442
+ return true;
443
+ },
444
+ },
445
+ }),
446
+ ];
447
+ },
448
+ });
449
+
450
+ export interface ChipHit {
451
+ start: number;
452
+ end: number;
453
+ attrs: Record<string, unknown>;
454
+ }
455
+
456
+ /**
457
+ * Which spans of a pasted string become chips, in order and non-overlapping.
458
+ *
459
+ * Exported so the paste rule is testable without an editor.
460
+ */
461
+ export function chipHits(
462
+ text: string,
463
+ enablePaths: boolean,
464
+ enableUrls: boolean,
465
+ ): ChipHit[] {
466
+ const hits: ChipHit[] = [];
467
+ if (enablePaths) {
468
+ for (const m of findFilePaths(text)) {
469
+ hits.push({ start: m.start, end: m.end, attrs: { kind: 'path', path: m.path } });
470
+ }
471
+ }
472
+ if (enableUrls) {
473
+ for (const m of findUrls(text)) {
474
+ hits.push({ start: m.start, end: m.end, attrs: { kind: 'url', href: m.href } });
475
+ }
476
+ }
477
+ hits.sort((a, b) => a.start - b.start);
478
+ // Drop overlaps (a path inside a URL etc.) — first wins.
479
+ const merged: ChipHit[] = [];
480
+ let cursor = 0;
481
+ for (const h of hits) {
482
+ if (h.start < cursor) continue;
483
+ merged.push(h);
484
+ cursor = h.end;
485
+ }
486
+ return merged;
487
+ }
488
+
489
+ /**
490
+ * Split a pasted string into lines, with chip spans marked.
491
+ *
492
+ * The shape the paste rule builds nodes from: one entry per LINE, each a list
493
+ * of text runs and chips. Pure, so the line-splitting that a multi-line
494
+ * terminal paste depends on is testable on its own.
495
+ */
496
+ export function chipPasteLines(
497
+ text: string,
498
+ enablePaths: boolean,
499
+ enableUrls: boolean,
500
+ ): Array<Array<{ text: string } | { chip: Record<string, unknown> }>> {
501
+ const merged = chipHits(text, enablePaths, enableUrls);
502
+ const lines: Array<Array<{ text: string } | { chip: Record<string, unknown> }>> = [];
503
+ let line: Array<{ text: string } | { chip: Record<string, unknown> }> = [];
504
+ const pushText = (s: string) => {
505
+ if (!s) return;
506
+ const segments = s.split(/\r\n?|\n/);
507
+ segments.forEach((segment, i) => {
508
+ if (i > 0) {
509
+ lines.push(line);
510
+ line = [];
511
+ }
512
+ if (segment) line.push({ text: segment });
513
+ });
514
+ };
515
+ let last = 0;
516
+ for (const h of merged) {
517
+ pushText(text.slice(last, h.start));
518
+ line.push({ chip: h.attrs });
519
+ last = h.end;
520
+ }
521
+ pushText(text.slice(last));
522
+ lines.push(line);
523
+ return lines;
524
+ }
525
+
526
+ export default ChipNode;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `syncChips` — convert detected file-path / URL text in the document into
3
+ * `editorChip` atom nodes. The analogue of `syncLeadingSlashNode` for the
4
+ * chip node.
5
+ *
6
+ * Input/paste rules cover live typing + paste, but content arriving via
7
+ * `setContent(markdown)` (the controlled `value` sync) is parsed straight
8
+ * to plain text — a bare path or URL in the initial `value` would render
9
+ * un-chipped without this pass. We scan every text node, run the pure
10
+ * detectors, and replace each match range with a chip, walking
11
+ * right-to-left within each text node so earlier offsets stay valid.
12
+ *
13
+ * Returns `true` when at least one replacement happened (callers may
14
+ * suppress a feedback `onUpdate`).
15
+ */
16
+ import type { Editor } from '@tiptap/react';
17
+ import { findFilePaths } from '../filePath/detect';
18
+ import { findUrls } from '../url/detect';
19
+
20
+ interface ChipOpts {
21
+ paths: boolean;
22
+ urls: boolean;
23
+ }
24
+
25
+ interface PendingHit {
26
+ /** Absolute doc start. */
27
+ from: number;
28
+ /** Absolute doc end. */
29
+ to: number;
30
+ attrs: Record<string, unknown>;
31
+ }
32
+
33
+ export function syncChips(editor: Editor, opts: ChipOpts): boolean {
34
+ if (!editor || editor.isDestroyed) return false;
35
+ if (!opts.paths && !opts.urls) return false;
36
+ const chipType = editor.schema.nodes.editorChip;
37
+ if (!chipType) return false;
38
+
39
+ const hits: PendingHit[] = [];
40
+
41
+ editor.state.doc.descendants((node, pos) => {
42
+ if (!node.isText || !node.text) return;
43
+ const text = node.text;
44
+
45
+ const local: { start: number; end: number; attrs: Record<string, unknown> }[] = [];
46
+ if (opts.paths) {
47
+ for (const m of findFilePaths(text)) {
48
+ local.push({ start: m.start, end: m.end, attrs: { kind: 'path', path: m.path } });
49
+ }
50
+ }
51
+ if (opts.urls) {
52
+ for (const m of findUrls(text)) {
53
+ local.push({ start: m.start, end: m.end, attrs: { kind: 'url', href: m.href } });
54
+ }
55
+ }
56
+ if (local.length === 0) return;
57
+
58
+ // Drop overlaps (e.g. a path shape inside a URL path) — earliest start
59
+ // wins, longest at equal start.
60
+ local.sort((a, b) => a.start - b.start || b.end - a.end);
61
+ let cursor = 0;
62
+ for (const h of local) {
63
+ if (h.start < cursor) continue;
64
+ hits.push({ from: pos + h.start, to: pos + h.end, attrs: h.attrs });
65
+ cursor = h.end;
66
+ }
67
+ });
68
+
69
+ if (hits.length === 0) return false;
70
+
71
+ // Apply right-to-left so each replacement doesn't shift the positions of
72
+ // the ones still pending.
73
+ hits.sort((a, b) => b.from - a.from);
74
+ let tr = editor.state.tr;
75
+ for (const h of hits) {
76
+ tr = tr.replaceWith(h.from, h.to, chipType.create(h.attrs));
77
+ }
78
+ editor.view.dispatch(tr);
79
+ return true;
80
+ }