@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,131 @@
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 { MentionList, type MentionListRef } from './MentionList';
5
+ import type { MentionItem, MentionConfig } from './types';
6
+
7
+ export function createMentionSuggestion(
8
+ config: MentionConfig,
9
+ ): Omit<SuggestionOptions<MentionItem>, 'editor'> {
10
+ const { maxItems = 5, trigger = '@' } = config;
11
+
12
+ return {
13
+ char: trigger,
14
+
15
+ items: ({ query }) => {
16
+ const q = query.toLowerCase();
17
+ return config.items
18
+ .filter((item) => item.label.toLowerCase().includes(q))
19
+ .slice(0, maxItems);
20
+ },
21
+
22
+ render: () => {
23
+ let component: ReactRenderer<MentionListRef> | null = null;
24
+ let popup: HTMLDivElement | null = null;
25
+ let cleanupAutoUpdate: (() => void) | null = null;
26
+ let getReferenceRect: (() => DOMRect | null) | null = null;
27
+
28
+ // Floating-UI virtual element backed by Tiptap's clientRect.
29
+ // We re-read it on every reposition so caret movement is tracked.
30
+ const buildVirtualElement = () => ({
31
+ getBoundingClientRect: () => {
32
+ const rect = getReferenceRect?.();
33
+ // Fallback to a zero-sized rect at origin if the editor is detached
34
+ // (e.g. mid-teardown). Floating-UI tolerates this.
35
+ return rect ?? new DOMRect(0, 0, 0, 0);
36
+ },
37
+ });
38
+
39
+ // The popup wrapper is mounted in onStart and removed in onExit.
40
+ // While mounted it must stay hidden whenever MentionList renders
41
+ // nothing (empty query / no matches), otherwise an empty positioned
42
+ // div lingers next to the caret as a ghost block.
43
+ const setPopupVisible = (visible: boolean) => {
44
+ if (popup) popup.style.display = visible ? '' : 'none';
45
+ };
46
+
47
+ const updatePosition = () => {
48
+ if (!popup) return;
49
+ const virtualEl = buildVirtualElement();
50
+ void computePosition(virtualEl, popup, {
51
+ placement: 'bottom-start',
52
+ middleware: [
53
+ offset(4),
54
+ flip({ fallbackPlacements: ['top-start'] }),
55
+ shift({ padding: 8 }),
56
+ ],
57
+ }).then(({ x, y }) => {
58
+ if (!popup) return;
59
+ // transform is more performant than top/left and avoids
60
+ // sub-pixel layout thrash during scroll/resize.
61
+ popup.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
62
+ });
63
+ };
64
+
65
+ const teardown = () => {
66
+ cleanupAutoUpdate?.();
67
+ cleanupAutoUpdate = null;
68
+ popup?.remove();
69
+ popup = null;
70
+ component?.destroy();
71
+ component = null;
72
+ getReferenceRect = null;
73
+ };
74
+
75
+ return {
76
+ onStart: (props) => {
77
+ component = new ReactRenderer(MentionList, {
78
+ props: {
79
+ items: props.items,
80
+ command: (item: MentionItem) => {
81
+ props.command({ id: item.id, label: item.label });
82
+ },
83
+ },
84
+ editor: props.editor,
85
+ });
86
+
87
+ popup = document.createElement('div');
88
+ // top/left at 0; actual position is applied via transform by computePosition.
89
+ popup.style.cssText = 'position: absolute; top: 0; left: 0; z-index: 99999;';
90
+ popup.appendChild(component.element);
91
+ document.body.appendChild(popup);
92
+ setPopupVisible(props.items.length > 0);
93
+
94
+ getReferenceRect = () => props.clientRect?.() ?? null;
95
+
96
+ // autoUpdate handles scroll, resize, ancestor scroll/resize, layout shifts.
97
+ // It calls updatePosition synchronously on registration too — no manual first call needed.
98
+ const virtualEl = buildVirtualElement();
99
+ cleanupAutoUpdate = autoUpdate(virtualEl, popup, updatePosition);
100
+ },
101
+
102
+ onUpdate: (props) => {
103
+ component?.updateProps({
104
+ items: props.items,
105
+ command: (item: MentionItem) => {
106
+ props.command({ id: item.id, label: item.label });
107
+ },
108
+ });
109
+
110
+ setPopupVisible(props.items.length > 0);
111
+
112
+ // Refresh reference accessor so autoUpdate sees the new caret rect.
113
+ getReferenceRect = () => props.clientRect?.() ?? null;
114
+ updatePosition();
115
+ },
116
+
117
+ onKeyDown: (props) => {
118
+ if (props.event.key === 'Escape') {
119
+ teardown();
120
+ return true;
121
+ }
122
+ return component?.ref?.onKeyDown(props.event as unknown as React.KeyboardEvent) ?? false;
123
+ },
124
+
125
+ onExit: () => {
126
+ teardown();
127
+ },
128
+ };
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `<FilePathChip>` — a compact, presentational chip for a LOCAL file path
5
+ * (Cursor / VSCode / Finder style): the extension-correct file icon +
6
+ * a smart middle-ellipsized label, monospace, with the full path on hover.
7
+ *
8
+ * Standalone by design — it imports only the pure detector helpers and the
9
+ * shared `FileIcon`, so it can render a known path in a chat message,
10
+ * a file list, etc., entirely outside the TipTap editor.
11
+ *
12
+ * Presentational only: it never mutates or links anything. Inside the
13
+ * editor the SAME visual is reproduced by the `editorChip` atom NODE
14
+ * (see `../chip/ChipNode.ts`, `kind: 'path'`) — this component is the
15
+ * React/standalone counterpart, still useful for rendering a known path
16
+ * outside the editor (chat messages, file lists).
17
+ */
18
+
19
+ import { cn } from '@djangocfg/ui-core/lib';
20
+ import { FileIcon } from '@djangocfg/widget-data/file-icon';
21
+ import { splitPath, truncatePathLabel } from './detect';
22
+
23
+ export interface FilePathChipProps {
24
+ /** The full, raw file path (Unix / macOS / Windows / UNC / `file://`). */
25
+ path: string;
26
+ /**
27
+ * Approximate max characters for the truncated label. The basename is
28
+ * always preserved; only the middle collapses. Default 40.
29
+ */
30
+ maxChars?: number;
31
+ /** Icon size in px. Default 14 (matches inline text). */
32
+ iconSize?: 12 | 14 | 16;
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * Render a path as an inline chip. Uses the folder icon when the path
38
+ * resolves to a directory (no file extension / trailing separator).
39
+ */
40
+ export function FilePathChip({
41
+ path,
42
+ maxChars = 40,
43
+ iconSize = 14,
44
+ className,
45
+ }: FilePathChipProps) {
46
+ const { base, isDir } = splitPath(path);
47
+ const label = truncatePathLabel(path, { maxChars });
48
+
49
+ return (
50
+ <span
51
+ className={cn(
52
+ // Inline pill: muted surface + border, monospace, semantic tokens
53
+ // only (mirrors the inline-code look). `max-w` + `truncate` is the
54
+ // CSS safety net under the semantic middle-ellipsis label.
55
+ 'inline-flex max-w-[22rem] items-center gap-1 rounded',
56
+ 'border border-border bg-muted px-1.5 py-0.5 align-baseline',
57
+ 'font-mono text-[0.8125em] leading-tight text-foreground',
58
+ className,
59
+ )}
60
+ title={path}
61
+ >
62
+ <FileIcon name={base} isFolder={isDir} size={iconSize} />
63
+ <span className="truncate">{label}</span>
64
+ </span>
65
+ );
66
+ }
67
+
68
+ export default FilePathChip;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * File-path detection + display helpers for the chat editor's "file chip"
3
+ * decoration. Pure, side-effect-free, and unit-testable — no TipTap /
4
+ * ProseMirror / React imports live here so the same functions back the
5
+ * standalone `<FilePathChip>` and the editor decoration alike.
6
+ *
7
+ * Design goal: **LOW false-positive**. We only treat a token as a local
8
+ * file path when it matches an explicit, unambiguous ABSOLUTE shape —
9
+ * never a bare relative token (`./x`, `src/y`) and never a web URL. See
10
+ * `reports/research.md` for the rationale (there is no single universal
11
+ * path regex; detect by safe shapes only).
12
+ */
13
+
14
+ // ── Match result ──
15
+
16
+ export interface FilePathMatch {
17
+ /** Inclusive start offset into the scanned string. */
18
+ start: number;
19
+ /** Exclusive end offset into the scanned string. */
20
+ end: number;
21
+ /** The matched path text (already trailing-punctuation-trimmed). */
22
+ path: string;
23
+ }
24
+
25
+ // ── Detector ──
26
+
27
+ /**
28
+ * Trailing sentence punctuation that, when it hangs off the END of a
29
+ * detected path token, is almost certainly prose and not part of the
30
+ * filename — trim it. Real filenames effectively never end in these.
31
+ * (A leading `.` like a dotfile is fine — we only trim the tail.)
32
+ */
33
+ const TRAILING_PUNCT = new Set([
34
+ '.', ',', ')', ']', '}', ';', ':', '!', '?',
35
+ '«', '»', '"', "'", '”', '’', '…',
36
+ ]);
37
+
38
+ /**
39
+ * The four absolute path shapes, as a single alternation. Each alternative
40
+ * is whitespace-bounded by construction (`\S` runs, no raw spaces). Order
41
+ * matters: more specific schemes (`file://`, UNC, drive) come before the
42
+ * bare Unix root so the longest correct form wins.
43
+ *
44
+ * - `file://…` — `file:` URI (any non-space tail).
45
+ * - `\\server\share…` — Windows UNC (two leading backslashes).
46
+ * - `C:\…` / `C:/…` — Windows drive (single letter + `:` + slash).
47
+ * - `~/…` — home-relative (Unix/macOS).
48
+ * - `/seg/seg…` — Unix/macOS absolute, REQUIRES ≥2 segments so a
49
+ * lone `/` or prose `a / b` never matches.
50
+ *
51
+ * Unicode is allowed inside segments (`@projects`, Cyrillic dirs) — we
52
+ * match on "not whitespace" rather than an ASCII allow-list, then strip
53
+ * trailing punctuation afterwards.
54
+ */
55
+ const PATH_RE = new RegExp(
56
+ [
57
+ // file:// URI — scheme then a non-space tail.
58
+ String.raw`file:\/\/\S+`,
59
+ // UNC: \\server\share... (need a host char after the two slashes).
60
+ String.raw`\\\\[^\s\\/][^\s]*`,
61
+ // Windows drive: C:\... or C:/... (single drive letter).
62
+ String.raw`[A-Za-z]:[\\/][^\s]*`,
63
+ // Home: ~/...
64
+ String.raw`~[\\/][^\s]+`,
65
+ // Unix/macOS absolute: /seg/seg... — at least TWO segments. A
66
+ // segment is a run of non-space, non-separator chars; we require
67
+ // a separator + segment to repeat at least once after the root.
68
+ String.raw`\/[^\s\\/]+(?:[\\/][^\s\\/]+)+[\\/]?`,
69
+ ].join('|'),
70
+ 'gu',
71
+ );
72
+
73
+ /**
74
+ * Trim trailing prose punctuation from a raw token. Repeats so multiple
75
+ * trailing marks (`manifest.md).` → `manifest.md`) all come off, but
76
+ * stops as soon as the tail is a normal path char. We never trim into an
77
+ * empty string.
78
+ */
79
+ function trimTrailingPunct(raw: string): string {
80
+ let end = raw.length;
81
+ while (end > 1 && TRAILING_PUNCT.has(raw[end - 1]!)) {
82
+ end -= 1;
83
+ }
84
+ return raw.slice(0, end);
85
+ }
86
+
87
+ /**
88
+ * Find every absolute local file path in `text`.
89
+ *
90
+ * Returns matches in document order with `{ start, end, path }` where
91
+ * `[start, end)` are offsets into the ORIGINAL string (after trailing-
92
+ * punctuation trimming, `end` points just past the kept path — the
93
+ * trimmed punctuation is left out of the range so the chip doesn't cover
94
+ * it). Web URLs (`https://x.com/y`), bare relative tokens, and a lone
95
+ * prose slash are intentionally NOT matched.
96
+ */
97
+ export function findFilePaths(text: string): FilePathMatch[] {
98
+ if (!text) return [];
99
+
100
+ const out: FilePathMatch[] = [];
101
+ PATH_RE.lastIndex = 0;
102
+ let m: RegExpExecArray | null;
103
+
104
+ while ((m = PATH_RE.exec(text)) !== null) {
105
+ const raw = m[0];
106
+ const start = m.index;
107
+
108
+ // Guard against a zero-width match (shouldn't happen with these
109
+ // alternatives, but keep the loop safe).
110
+ if (raw.length === 0) {
111
+ PATH_RE.lastIndex += 1;
112
+ continue;
113
+ }
114
+
115
+ // Leading-boundary guard. A real path token starts the string or
116
+ // follows whitespace / an opening delimiter. If it's glued onto a
117
+ // preceding non-space char it's a SUBSTRING of a larger token — most
118
+ // importantly a web URL's path (`https://x.com/y/z` → the `/y/z`
119
+ // would otherwise match the Unix-absolute shape). Reject those.
120
+ if (start > 0) {
121
+ const prev = text[start - 1]!;
122
+ if (!/[\s([{<«"'‘“]/.test(prev)) continue;
123
+ }
124
+
125
+ const trimmed = trimTrailingPunct(raw);
126
+ // A bare drive root (`C:\`) or `~/` with nothing meaningful is not a
127
+ // useful chip — but the regexes above already require a tail segment,
128
+ // so `trimmed` here is always a real path. Still, skip if trimming
129
+ // collapsed it to a non-path remnant.
130
+ if (trimmed.length < 2) continue;
131
+
132
+ out.push({
133
+ start,
134
+ end: start + trimmed.length,
135
+ path: trimmed,
136
+ });
137
+ }
138
+
139
+ return out;
140
+ }
141
+
142
+ // ── Split (separator-agnostic) ──
143
+
144
+ export interface SplitPath {
145
+ /** Everything before the basename (no trailing separator). */
146
+ dir: string;
147
+ /** Last non-empty path segment (the file or folder name). */
148
+ base: string;
149
+ /**
150
+ * Heuristic: the path points at a directory rather than a file —
151
+ * either it ends in a separator, or its basename has no file
152
+ * extension (a dotted `manifest.md` reads as a file; `positioning`
153
+ * reads as a dir).
154
+ */
155
+ isDir: boolean;
156
+ }
157
+
158
+ /**
159
+ * Strip a `file://` (or `file:`) scheme prefix for display/splitting.
160
+ * `file:///Users/x` → `/Users/x`; `file://host/share` → `host/share`.
161
+ */
162
+ function stripFileScheme(path: string): string {
163
+ const lower = path.toLowerCase();
164
+ if (lower.startsWith('file://')) {
165
+ // file:///abs → /abs (three slashes: empty host, keep the root)
166
+ // file://host → host
167
+ return path.slice('file://'.length);
168
+ }
169
+ if (lower.startsWith('file:')) return path.slice('file:'.length);
170
+ return path;
171
+ }
172
+
173
+ /** True when `base` looks like it carries a real file extension. */
174
+ function hasExtension(base: string): boolean {
175
+ const dot = base.lastIndexOf('.');
176
+ // No dot, leading dot only (dotfile like `.gitignore` — treat as file),
177
+ // or a trailing dot → decide:
178
+ // - `.gitignore` (dot at 0) IS a file → has "extension".
179
+ // - `name.` (dot last) → not a real extension.
180
+ if (dot === -1) return false;
181
+ if (dot === base.length - 1) return false; // trailing dot
182
+ if (dot === 0) return true; // dotfile
183
+ return true;
184
+ }
185
+
186
+ /**
187
+ * Split a path into `{ dir, base, isDir }`, separator-agnostic (handles
188
+ * `/` and `\` mixed). The basename is the last NON-EMPTY segment so a
189
+ * trailing separator (`/Users/me/docs/`) still yields `base = "docs"`
190
+ * and `isDir = true`.
191
+ */
192
+ export function splitPath(path: string): SplitPath {
193
+ const cleaned = stripFileScheme(path);
194
+ const endsWithSep = /[\\/]$/.test(cleaned);
195
+
196
+ // Split on either separator, drop empty segments (collapses `//`,
197
+ // leading root, trailing sep).
198
+ const segments = cleaned.split(/[\\/]+/).filter((s) => s.length > 0);
199
+ const base = segments.length > 0 ? segments[segments.length - 1]! : cleaned;
200
+ const dirSegments = segments.slice(0, -1);
201
+ const dir = dirSegments.join('/');
202
+
203
+ const isDir = endsWithSep || !hasExtension(base);
204
+
205
+ return { dir, base, isDir };
206
+ }
207
+
208
+ // ── Truncate (Apple/Finder middle-ellipsis) ──
209
+
210
+ export interface TruncateOptions {
211
+ /**
212
+ * Approximate max characters for the label. The basename is ALWAYS
213
+ * kept in full (never cut); only the middle collapses. Default 40.
214
+ */
215
+ maxChars?: number;
216
+ }
217
+
218
+ const ELLIPSIS = '…';
219
+
220
+ /**
221
+ * Produce a compact, Finder/Chrome-style label for a path: the basename
222
+ * is sacred (always shown in full), the MIDDLE collapses to `…`, and a
223
+ * leading root hint is kept when there's room.
224
+ *
225
+ * Examples (maxChars ≈ 40):
226
+ * /Users/me/dev/proj/positioning/manifest.md
227
+ * → /Users/…/positioning/manifest.md
228
+ * /a/b.md → /a/b.md (already short — unchanged)
229
+ * C:\Users\me\notes.md → C:\…\notes.md (separator preserved? — we
230
+ * normalise display to `/` for compactness)
231
+ */
232
+ export function truncatePathLabel(path: string, opts: TruncateOptions = {}): string {
233
+ const maxChars = opts.maxChars ?? 40;
234
+ const display = stripFileScheme(path);
235
+
236
+ // Short enough — show as-is.
237
+ if (display.length <= maxChars) return display;
238
+
239
+ const endsWithSep = /[\\/]$/.test(display);
240
+ const segments = display.split(/[\\/]+/).filter((s) => s.length > 0);
241
+ if (segments.length <= 1) {
242
+ // Single segment longer than maxChars — keep the basename intact
243
+ // (CSS ellipsis is the safety net in the chip).
244
+ return display;
245
+ }
246
+
247
+ const base = segments[segments.length - 1]!;
248
+ const baseLabel = endsWithSep ? `${base}/` : base;
249
+
250
+ // Detect a leading root hint we can preserve:
251
+ // - Unix/macOS absolute → leading "/" + first segment.
252
+ // - Windows drive → "C:".
253
+ // - `~` home → "~".
254
+ let rootHint = '';
255
+ if (/^[A-Za-z]:[\\/]/.test(display)) {
256
+ rootHint = display.slice(0, 2); // "C:"
257
+ } else if (display.startsWith('~')) {
258
+ rootHint = '~';
259
+ } else if (/^[\\/]/.test(display)) {
260
+ rootHint = `/${segments[0]!}`;
261
+ }
262
+
263
+ // The penultimate segment (parent folder) is a useful hint to keep
264
+ // when room allows — `.../positioning/manifest.md`.
265
+ const parent = segments.length >= 2 ? segments[segments.length - 2]! : '';
266
+
267
+ // Build candidates from most-informative to least, pick the first that
268
+ // fits within maxChars (the basename is never sacrificed).
269
+ const candidates: string[] = [];
270
+ if (rootHint && parent) {
271
+ candidates.push(`${rootHint}/${ELLIPSIS}/${parent}/${baseLabel}`);
272
+ }
273
+ if (parent) {
274
+ candidates.push(`${ELLIPSIS}/${parent}/${baseLabel}`);
275
+ }
276
+ if (rootHint) {
277
+ candidates.push(`${rootHint}/${ELLIPSIS}/${baseLabel}`);
278
+ }
279
+ candidates.push(`${ELLIPSIS}/${baseLabel}`);
280
+
281
+ for (const c of candidates) {
282
+ if (c.length <= maxChars) return c;
283
+ }
284
+
285
+ // Even `…/base` overflows (a very long basename) — return it anyway;
286
+ // the chip's CSS `text-overflow: ellipsis` clips the tail visually
287
+ // while the full path stays in the tooltip.
288
+ return `${ELLIPSIS}/${baseLabel}`;
289
+ }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ export { FilePathChip } from './FilePathChip';
4
+ export type { FilePathChipProps } from './FilePathChip';
5
+
6
+ export {
7
+ findFilePaths,
8
+ splitPath,
9
+ truncatePathLabel,
10
+ } from './detect';
11
+ export type {
12
+ FilePathMatch,
13
+ SplitPath,
14
+ TruncateOptions,
15
+ } from './detect';
@@ -0,0 +1,29 @@
1
+ export { MarkdownEditor } from './MarkdownEditor';
2
+ export type { MarkdownEditorProps, MarkdownEditorHandle } from './MarkdownEditor';
3
+ export type {
4
+ MentionItem,
5
+ MentionConfig,
6
+ MentionAttrs,
7
+ MentionMarkdownRenderer,
8
+ } from './types';
9
+ export { mentionPresets } from './mentionPresets';
10
+ export type { SlashCommandInfo } from './slash/types';
11
+
12
+ // File-path chip — the standalone presentational chip plus the pure
13
+ // detector helpers (usable outside the editor, e.g. rendering a known
14
+ // path in a chat message).
15
+ export { FilePathChip } from './filePath/FilePathChip';
16
+ export type { FilePathChipProps } from './filePath/FilePathChip';
17
+ export { findFilePaths, splitPath, truncatePathLabel } from './filePath/detect';
18
+ export type { FilePathMatch, SplitPath, TruncateOptions } from './filePath/detect';
19
+
20
+ // URL chip — pure detector + display helpers backing the in-editor URL chip
21
+ // (the `editorChip` node's `kind: 'url'`). Usable standalone too.
22
+ export {
23
+ findUrls,
24
+ splitUrl,
25
+ truncateUrlLabel,
26
+ faviconUrl,
27
+ normalizeHref,
28
+ } from './url/detect';
29
+ export type { UrlMatch, SplitUrl, UrlLabelOptions } from './url/detect';
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `@djangocfg/widget-editor/markdown` subpath entrypoint.
5
+ *
6
+ * `MarkdownEditor` is a TipTap WYSIWYG with the full ProseMirror + TipTap
7
+ * extension stack — `@tiptap/react`, `starter-kit`, `markdown`, `mention`,
8
+ * `placeholder`, plus our `createMentionSuggestion` (floating-ui anchored
9
+ * dropdown). Together that's ~200 KB minified — wrap it in React.lazy so
10
+ * pages that don't render an editor don't pay.
11
+ *
12
+ * Why a hand-rolled lazy wrapper instead of `createLazyComponent`:
13
+ * `createLazyComponent` returns a plain function component that does not
14
+ * forward `ref`, so the imperative `MarkdownEditorHandle` (focus /
15
+ * moveCursorToEnd — used by chat composer registration + voice slot)
16
+ * would be silently dropped. The wrapper below is a `forwardRef` so the
17
+ * ref reaches the underlying TipTap editor through `React.lazy`.
18
+ *
19
+ * Light surface kept here:
20
+ * - All public types (erased at compile time).
21
+ * - `mentionPresets` — pure data describing how to render mentions to
22
+ * markdown. No TipTap imports at module scope.
23
+ */
24
+
25
+ import { Suspense, lazy, forwardRef } from 'react';
26
+ import { InlineLoadingFallback } from '@djangocfg/widget-kit/lazy';
27
+ import type { MarkdownEditorProps, MarkdownEditorHandle } from './MarkdownEditor';
28
+
29
+ const MarkdownEditorImpl = lazy(() =>
30
+ import('./MarkdownEditor').then((m) => ({ default: m.MarkdownEditor })),
31
+ );
32
+
33
+ /**
34
+ * Lazy-loaded `MarkdownEditor`. Forwards `ref` (`MarkdownEditorHandle`)
35
+ * to the underlying TipTap editor once the chunk resolves.
36
+ */
37
+ export const LazyMarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
38
+ function LazyMarkdownEditor(props, ref) {
39
+ // Input-sized, not panel-sized: this editor IS the composer's text field,
40
+ // so the 140px tinted block with a 32px disc and a caption was out of
41
+ // scale and shifted the layout when TipTap mounted. A bare 14px spinner is
42
+ // enough — the wait is short and nothing is actionable during it. The
43
+ // label survives for screen readers.
44
+ return (
45
+ <Suspense fallback={<InlineLoadingFallback />}>
46
+ <MarkdownEditorImpl {...props} ref={ref} />
47
+ </Suspense>
48
+ );
49
+ },
50
+ );
51
+
52
+ // `MarkdownEditor` is the public eager-looking name — it resolves to the
53
+ // same lazy component so importing it from this subpath does NOT pull
54
+ // ~200 KB of TipTap into the caller's initial bundle. Keeping the name
55
+ // here means `@djangocfg/widget-editor/markdown` exposes
56
+ // `MarkdownEditor`, `LazyMarkdownEditor`, and `mentionPresets`.
57
+ export { LazyMarkdownEditor as MarkdownEditor };
58
+
59
+ // Light surface — pure helpers + types.
60
+ export { mentionPresets } from './mentionPresets';
61
+
62
+ // File-path chip — the standalone presentational chip + the pure detector
63
+ // helpers. The chip is light enough to export eagerly (no TipTap at module
64
+ // scope — it only pulls `FileIcon` + the pure detector), so consumers can
65
+ // render a known path outside the editor without loading the ~200 KB
66
+ // TipTap chunk.
67
+ export { FilePathChip } from './filePath/FilePathChip';
68
+ export type { FilePathChipProps } from './filePath/FilePathChip';
69
+ export { findFilePaths, splitPath, truncatePathLabel } from './filePath/detect';
70
+ export type { FilePathMatch, SplitPath, TruncateOptions } from './filePath/detect';
71
+
72
+ export type {
73
+ MarkdownEditorProps,
74
+ MarkdownEditorHandle,
75
+ } from './MarkdownEditor';
76
+
77
+ export type {
78
+ MentionItem,
79
+ MentionConfig,
80
+ MentionAttrs,
81
+ MentionMarkdownRenderer,
82
+ } from './types';
83
+
84
+ // Slash command surface — opt-in by passing `slashCommands` to
85
+ // `MarkdownEditor` (or `<ComposerRichTextarea>`, which forwards). The
86
+ // node itself is mounted by the editor on first render. `SlashCommandInfo`
87
+ // is structurally compatible with the chat package's `SlashCommand`.
88
+ export type { SlashCommandInfo } from './slash/types';