@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,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';
|