@djangocfg/ui-tools 2.1.290 → 2.1.292
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/README.md +3 -2
- package/dist/{DocsLayout-IKH7BLSU.cjs → DocsLayout-5WTQR3BR.cjs} +3 -3
- package/dist/DocsLayout-5WTQR3BR.cjs.map +1 -0
- package/dist/{DocsLayout-JPXFUKAR.mjs → DocsLayout-YZR5RLCJ.mjs} +3 -3
- package/dist/DocsLayout-YZR5RLCJ.mjs.map +1 -0
- package/dist/index.cjs +81 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +83 -7
- package/dist/index.d.ts +83 -7
- package/dist/index.mjs +81 -19
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +108 -2
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +88 -3
- package/src/tools/MarkdownEditor/README.md +82 -3
- package/src/tools/MarkdownEditor/createMentionSuggestion.ts +55 -16
- package/src/tools/MarkdownEditor/index.ts +7 -1
- package/src/tools/MarkdownEditor/mentionPresets.test.ts +107 -0
- package/src/tools/MarkdownEditor/mentionPresets.ts +49 -0
- package/src/tools/MarkdownEditor/types.ts +33 -2
- package/src/tools/OpenapiViewer/README.md +2 -1
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +5 -1
- package/src/tools/OpenapiViewer/types.ts +5 -5
- package/src/tools/index.ts +8 -2
- package/dist/DocsLayout-IKH7BLSU.cjs.map +0 -1
- package/dist/DocsLayout-JPXFUKAR.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.292",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -74,7 +74,8 @@
|
|
|
74
74
|
"import": "./src/tools/CodeEditor/index.ts",
|
|
75
75
|
"require": "./src/tools/CodeEditor/index.ts"
|
|
76
76
|
},
|
|
77
|
-
"./styles": "./src/styles/index.css"
|
|
77
|
+
"./styles": "./src/styles/index.css",
|
|
78
|
+
"./dist.css": "./dist/index.css"
|
|
78
79
|
},
|
|
79
80
|
"files": [
|
|
80
81
|
"dist",
|
|
@@ -90,8 +91,8 @@
|
|
|
90
91
|
"check": "tsc --noEmit"
|
|
91
92
|
},
|
|
92
93
|
"peerDependencies": {
|
|
93
|
-
"@djangocfg/i18n": "^2.1.
|
|
94
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/i18n": "^2.1.292",
|
|
95
|
+
"@djangocfg/ui-core": "^2.1.292",
|
|
95
96
|
"consola": "^3.4.2",
|
|
96
97
|
"lodash-es": "^4.18.1",
|
|
97
98
|
"lucide-react": "^0.545.0",
|
|
@@ -101,6 +102,7 @@
|
|
|
101
102
|
"zustand": "^5.0.0"
|
|
102
103
|
},
|
|
103
104
|
"dependencies": {
|
|
105
|
+
"@floating-ui/dom": "^1.7.4",
|
|
104
106
|
"@rjsf/core": "^6.1.2",
|
|
105
107
|
"@rjsf/utils": "^6.1.2",
|
|
106
108
|
"@rjsf/validator-ajv8": "^6.1.2",
|
|
@@ -138,10 +140,10 @@
|
|
|
138
140
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
139
141
|
},
|
|
140
142
|
"devDependencies": {
|
|
141
|
-
"@djangocfg/i18n": "^2.1.
|
|
143
|
+
"@djangocfg/i18n": "^2.1.292",
|
|
142
144
|
"@djangocfg/playground": "workspace:*",
|
|
143
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
144
|
-
"@djangocfg/ui-core": "^2.1.
|
|
145
|
+
"@djangocfg/typescript-config": "^2.1.292",
|
|
146
|
+
"@djangocfg/ui-core": "^2.1.292",
|
|
145
147
|
"@types/lodash-es": "^4.17.12",
|
|
146
148
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
147
149
|
"@types/node": "^24.7.2",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineStory } from '@djangocfg/playground';
|
|
2
2
|
import { MarkdownEditor } from './MarkdownEditor';
|
|
3
|
-
import {
|
|
4
|
-
import type
|
|
3
|
+
import { mentionPresets } from './mentionPresets';
|
|
4
|
+
import { useState, type ReactNode } from 'react';
|
|
5
|
+
import type { MentionConfig, MentionMarkdownRenderer } from './types';
|
|
5
6
|
|
|
6
7
|
export default defineStory({
|
|
7
8
|
title: 'Tools/Markdown Editor',
|
|
@@ -109,6 +110,111 @@ export function Compact() {
|
|
|
109
110
|
);
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
export function WithCustomUriPreset() {
|
|
114
|
+
const [value, setValue] = useState(
|
|
115
|
+
'Reference @Alice and @Bob — markdown will carry machine-readable URIs.\n\nType @ to add more.',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<SerializationDemo
|
|
120
|
+
description={
|
|
121
|
+
<>
|
|
122
|
+
<code>mentionPresets.customUri('myapp', 'user')</code> — emits
|
|
123
|
+
<code>{' @[Label](myapp://user/id)'}</code>. Useful when downstream
|
|
124
|
+
parses the markdown back into deep-links while keeping the visible
|
|
125
|
+
<code>@</code> handle.
|
|
126
|
+
</>
|
|
127
|
+
}
|
|
128
|
+
value={value}
|
|
129
|
+
onChange={setValue}
|
|
130
|
+
renderMarkdown={mentionPresets.customUri('myapp', 'user')}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function WithMarkdownLinkPreset() {
|
|
136
|
+
const [value, setValue] = useState('Ping @Alice when ready, cc @Charlie.');
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<SerializationDemo
|
|
140
|
+
description={
|
|
141
|
+
<>
|
|
142
|
+
<code>mentionPresets.markdownLink('https://example.com/u/')</code> —
|
|
143
|
+
serializes mentions as ordinary clickable links: <code>[@Label](https://example.com/u/id)</code>.
|
|
144
|
+
</>
|
|
145
|
+
}
|
|
146
|
+
value={value}
|
|
147
|
+
onChange={setValue}
|
|
148
|
+
renderMarkdown={mentionPresets.markdownLink('https://example.com/u/')}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function WithCustomRenderer() {
|
|
154
|
+
const [value, setValue] = useState('Tag @Alice and @Bob to assign the task.');
|
|
155
|
+
|
|
156
|
+
// Hand-rolled serializer — any (attrs) => string works.
|
|
157
|
+
const renderMarkdown: MentionMarkdownRenderer = ({ id, label }) =>
|
|
158
|
+
`{{user:${id}|${label || 'unknown'}}}`;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<SerializationDemo
|
|
162
|
+
description={
|
|
163
|
+
<>
|
|
164
|
+
Inline custom renderer: <code>{`({ id, label }) => \`{{user:\${id}|\${label}}}\``}</code>.
|
|
165
|
+
Shows that the serializer is just a function — you control the wire format.
|
|
166
|
+
</>
|
|
167
|
+
}
|
|
168
|
+
value={value}
|
|
169
|
+
onChange={setValue}
|
|
170
|
+
renderMarkdown={renderMarkdown}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface SerializationDemoProps {
|
|
176
|
+
description: ReactNode;
|
|
177
|
+
value: string;
|
|
178
|
+
onChange: (v: string) => void;
|
|
179
|
+
renderMarkdown: MentionMarkdownRenderer;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function SerializationDemo({ description, value, onChange, renderMarkdown }: SerializationDemoProps) {
|
|
183
|
+
const mentions: MentionConfig = { ...MENTION_ITEMS, renderMarkdown };
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, maxWidth: 900 }}>
|
|
187
|
+
<div>
|
|
188
|
+
<p style={{ fontSize: 12, opacity: 0.7, marginBottom: 8 }}>{description}</p>
|
|
189
|
+
<MarkdownEditor
|
|
190
|
+
value={value}
|
|
191
|
+
onChange={onChange}
|
|
192
|
+
mentions={mentions}
|
|
193
|
+
placeholder="Type @ to insert a mention..."
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
<div>
|
|
197
|
+
<div style={{ fontSize: 11, opacity: 0.5, marginBottom: 4, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
198
|
+
Serialized markdown
|
|
199
|
+
</div>
|
|
200
|
+
<pre
|
|
201
|
+
style={{
|
|
202
|
+
fontSize: 12,
|
|
203
|
+
padding: 12,
|
|
204
|
+
background: 'rgba(127,127,127,0.08)',
|
|
205
|
+
borderRadius: 6,
|
|
206
|
+
whiteSpace: 'pre-wrap',
|
|
207
|
+
wordBreak: 'break-word',
|
|
208
|
+
margin: 0,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
{value || '(empty)'}
|
|
212
|
+
</pre>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
112
218
|
function RawPreview({ value }: { value: string }) {
|
|
113
219
|
return (
|
|
114
220
|
<details style={{ marginTop: 16 }}>
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
List, ListOrdered, Quote, Minus, Code, type LucideIcon,
|
|
13
13
|
} from 'lucide-react';
|
|
14
14
|
import { createMentionSuggestion } from './createMentionSuggestion';
|
|
15
|
-
import
|
|
15
|
+
import { mentionPresets } from './mentionPresets';
|
|
16
|
+
import type { MentionAttrs, MentionConfig } from './types';
|
|
16
17
|
import './styles.css';
|
|
17
18
|
|
|
18
19
|
// ── Helpers ──
|
|
@@ -47,7 +48,23 @@ export interface MarkdownEditorProps {
|
|
|
47
48
|
className?: string;
|
|
48
49
|
disabled?: boolean;
|
|
49
50
|
showToolbar?: boolean;
|
|
50
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* `@`-mention autocomplete config.
|
|
53
|
+
*
|
|
54
|
+
* IMPORTANT: Tiptap's `useEditor` initialises the editor exactly once.
|
|
55
|
+
* The `Mention` extension is only registered when `mentions` is truthy
|
|
56
|
+
* on the FIRST render — handing in a real config later (e.g. after an
|
|
57
|
+
* async items fetch) silently does nothing, and typing `@` will not
|
|
58
|
+
* open the popover.
|
|
59
|
+
*
|
|
60
|
+
* If you want mentions even with async-loaded items, pass
|
|
61
|
+
* `{ items: [] }` from the very first render and update the array
|
|
62
|
+
* when data arrives. Either keep the `MentionConfig` object identity
|
|
63
|
+
* stable across renders and mutate `items` in place (the suggestion
|
|
64
|
+
* plugin captures the config by closure and reads `items` on each
|
|
65
|
+
* query), or accept that swapping the whole object reference is a
|
|
66
|
+
* no-op for the live editor.
|
|
67
|
+
*/
|
|
51
68
|
mentions?: MentionConfig;
|
|
52
69
|
/** Called when mentioned IDs change */
|
|
53
70
|
onMentionIdsChange?: (ids: string[]) => void;
|
|
@@ -68,6 +85,31 @@ export function MarkdownEditor({
|
|
|
68
85
|
}: MarkdownEditorProps) {
|
|
69
86
|
const isExternalUpdate = useRef(false);
|
|
70
87
|
|
|
88
|
+
// ── Dev-mode trap detector ──
|
|
89
|
+
// Tiptap initialises the editor once with the extensions array from
|
|
90
|
+
// first render. If `mentions` is undefined on mount and becomes
|
|
91
|
+
// truthy later, the Mention extension is never installed and the
|
|
92
|
+
// user-visible @-trigger silently does nothing. Catch this early so
|
|
93
|
+
// future consumers don't spend hours debugging why @ does nothing.
|
|
94
|
+
const initialMentionsDefinedRef = useRef<boolean>(mentions !== undefined);
|
|
95
|
+
const warnedRef = useRef(false);
|
|
96
|
+
if (
|
|
97
|
+
process.env.NODE_ENV !== 'production' &&
|
|
98
|
+
!initialMentionsDefinedRef.current &&
|
|
99
|
+
mentions !== undefined &&
|
|
100
|
+
!warnedRef.current
|
|
101
|
+
) {
|
|
102
|
+
warnedRef.current = true;
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
console.warn(
|
|
105
|
+
'[MarkdownEditor] `mentions` flipped from undefined to a config ' +
|
|
106
|
+
'after mount. Tiptap only installs the Mention extension on first ' +
|
|
107
|
+
'render — the @-popover will NOT work for this editor instance. ' +
|
|
108
|
+
'Pass `{ items: [] }` from the very first render and mutate `.items` ' +
|
|
109
|
+
'in place instead.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
71
113
|
const extensions = useMemo(() => {
|
|
72
114
|
const exts: AnyExtension[] = [
|
|
73
115
|
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
|
@@ -76,8 +118,51 @@ export function MarkdownEditor({
|
|
|
76
118
|
];
|
|
77
119
|
|
|
78
120
|
if (mentions) {
|
|
121
|
+
// ── Why .extend() with renderMarkdown ──
|
|
122
|
+
//
|
|
123
|
+
// Tiptap's `Mention` extension ships a default markdown serializer
|
|
124
|
+
// (via `createInlineMarkdownSpec`, see @tiptap/extension-mention)
|
|
125
|
+
// that emits a shortcode like `[@ id="..." label="..."]`. That's
|
|
126
|
+
// round-trippable but useless for most consumers: a chat composer
|
|
127
|
+
// feeding an LLM wants `@<label>`, a deep-linking app wants a
|
|
128
|
+
// markdown link, etc.
|
|
129
|
+
//
|
|
130
|
+
// `renderText` only affects `editor.getText()` and the rendered
|
|
131
|
+
// node — it does NOT influence `@tiptap/markdown`'s serializer.
|
|
132
|
+
// The serializer reads `renderMarkdown` off the extension config
|
|
133
|
+
// (see MarkdownManager.registerExtension → getExtensionField).
|
|
134
|
+
// Overriding it via `.extend({ renderMarkdown })` replaces the
|
|
135
|
+
// shortcode output with whatever the consumer supplied (or the
|
|
136
|
+
// `plainAt` default — see ./mentionPresets.ts).
|
|
137
|
+
//
|
|
138
|
+
// Renderer-choice capture: useEditor only initialises once, so the
|
|
139
|
+
// chosen renderer is captured by closure on first render. This is
|
|
140
|
+
// intentional — swapping renderers per render would mean tearing
|
|
141
|
+
// the editor down anyway, which we don't do for any other prop.
|
|
142
|
+
//
|
|
143
|
+
// Round-trip note: we intentionally do NOT also override
|
|
144
|
+
// `parseMarkdown` / `markdownTokenizer`. Once a mention is
|
|
145
|
+
// serialized, parsing it back would need the original mention
|
|
146
|
+
// items list to look up the id, which we don't have at parse time.
|
|
147
|
+
// Mentions are write-only by design here — `setContent(value)`
|
|
148
|
+
// after submit gets back a plain string, which is fine for the
|
|
149
|
+
// chat use case.
|
|
150
|
+
const renderMarkdown = mentions.renderMarkdown ?? mentionPresets.plainAt;
|
|
79
151
|
exts.push(
|
|
80
|
-
Mention.
|
|
152
|
+
Mention.extend({
|
|
153
|
+
renderMarkdown(node) {
|
|
154
|
+
const raw = node.attrs as { label?: string | null; id?: string | null };
|
|
155
|
+
const attrs: MentionAttrs = {
|
|
156
|
+
id: raw?.id ?? '',
|
|
157
|
+
label: raw?.label ?? '',
|
|
158
|
+
};
|
|
159
|
+
// Defensive: if both are empty (shouldn't happen for a real
|
|
160
|
+
// mention node, but `setContent` of malformed data could),
|
|
161
|
+
// emit nothing rather than a stray "@" or invalid link.
|
|
162
|
+
if (!attrs.id && !attrs.label) return '';
|
|
163
|
+
return renderMarkdown(attrs);
|
|
164
|
+
},
|
|
165
|
+
}).configure({
|
|
81
166
|
HTMLAttributes: { class: 'markdown-mention' },
|
|
82
167
|
suggestion: createMentionSuggestion(mentions),
|
|
83
168
|
renderText: ({ node }) => `@${node.attrs.label}`,
|
|
@@ -10,6 +10,8 @@ WYSIWYG markdown editor based on Tiptap. Renders markdown visually (headings, li
|
|
|
10
10
|
- Blockquotes with left border
|
|
11
11
|
- Horizontal rules
|
|
12
12
|
- Toolbar with icon buttons
|
|
13
|
+
- `@`-mentions with auto-flipping popup (`@floating-ui/dom`)
|
|
14
|
+
- Customizable markdown serialization for mentions (six built-in presets)
|
|
13
15
|
- Markdown input/output (stored as plain markdown string)
|
|
14
16
|
- SSR-safe (`immediatelyRender: false`)
|
|
15
17
|
|
|
@@ -31,6 +33,18 @@ function MyComponent() {
|
|
|
31
33
|
}
|
|
32
34
|
```
|
|
33
35
|
|
|
36
|
+
## CSS
|
|
37
|
+
|
|
38
|
+
Two ways to load styles, depending on your build:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// Tailwind-based apps — pulls in source utilities so Tailwind's JIT picks them up.
|
|
42
|
+
import '@djangocfg/ui-tools/styles';
|
|
43
|
+
|
|
44
|
+
// Plain Vite / webpack / CRA — pre-compiled CSS, no Tailwind required.
|
|
45
|
+
import '@djangocfg/ui-tools/dist.css';
|
|
46
|
+
```
|
|
47
|
+
|
|
34
48
|
## Props
|
|
35
49
|
|
|
36
50
|
| Prop | Type | Default | Description |
|
|
@@ -42,9 +56,18 @@ function MyComponent() {
|
|
|
42
56
|
| `className` | `string` | — | Additional CSS class |
|
|
43
57
|
| `disabled` | `boolean` | `false` | Read-only mode |
|
|
44
58
|
| `showToolbar` | `boolean` | `true` | Show formatting toolbar |
|
|
45
|
-
| `mentions` | `MentionConfig` | — |
|
|
59
|
+
| `mentions` | `MentionConfig` | — | `@`-mention autocomplete config |
|
|
46
60
|
| `onMentionIdsChange` | `(ids: string[]) => void` | — | Called when mentioned IDs change |
|
|
47
61
|
|
|
62
|
+
### `MentionConfig` fields
|
|
63
|
+
|
|
64
|
+
| Field | Type | Default | Description |
|
|
65
|
+
|-------|------|---------|-------------|
|
|
66
|
+
| `items` | `MentionItem[]` | required | Available mention items (`id`, `label`, optional `description`, `thumbnail`) |
|
|
67
|
+
| `trigger` | `string` | `'@'` | Trigger character |
|
|
68
|
+
| `maxItems` | `number` | `5` | Max items shown in dropdown |
|
|
69
|
+
| `renderMarkdown` | `MentionMarkdownRenderer` | `mentionPresets.plainAt` | Serializes a mention to markdown — see below |
|
|
70
|
+
|
|
48
71
|
## Mentions
|
|
49
72
|
|
|
50
73
|
```tsx
|
|
@@ -61,8 +84,64 @@ function MyComponent() {
|
|
|
61
84
|
/>
|
|
62
85
|
```
|
|
63
86
|
|
|
64
|
-
Type `@` to trigger autocomplete. Mentions render as inline chips.
|
|
87
|
+
Type `@` to trigger autocomplete. Mentions render as inline chips. The popup auto-flips above the trigger when there isn't enough room below, and tracks scroll/resize via `autoUpdate` — viewport-aware out of the box.
|
|
88
|
+
|
|
89
|
+
> Heads-up: Tiptap initialises the editor exactly once. If `mentions` is `undefined` on first render and becomes truthy later, the extension is never installed. Pass `{ items: [] }` from the start and mutate `.items` in place if items load async.
|
|
90
|
+
|
|
91
|
+
## Mention serialization
|
|
92
|
+
|
|
93
|
+
By default Tiptap's `Mention` extension emits a useless shortcode like `[@ id="..." label="..."]` into markdown. `MarkdownEditor` overrides that with the renderer from `MentionConfig.renderMarkdown` (defaults to `mentionPresets.plainAt`, which yields `@<label>`).
|
|
94
|
+
|
|
95
|
+
Six presets are exported from `@djangocfg/ui-tools`:
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
import { MarkdownEditor, mentionPresets } from '@djangocfg/ui-tools';
|
|
99
|
+
|
|
100
|
+
<MarkdownEditor
|
|
101
|
+
value={text}
|
|
102
|
+
onChange={setText}
|
|
103
|
+
mentions={{
|
|
104
|
+
items,
|
|
105
|
+
renderMarkdown: mentionPresets.customUri('cmdop', 'machine'),
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Preset | Output for `{ label: 'Vps-audi', id: 'uuid' }` |
|
|
111
|
+
|--------|-------------------------------------------------|
|
|
112
|
+
| `plainAt` *(default)* | `@Vps-audi` |
|
|
113
|
+
| `plainLabel` | `Vps-audi` |
|
|
114
|
+
| `markdownLink(baseUrl)` | `[@Vps-audi](baseUrl/uuid)` |
|
|
115
|
+
| `customUri(scheme, kind)` | `@[Vps-audi](scheme://kind/uuid)` |
|
|
116
|
+
| `slackStyle` | `<@uuid>` |
|
|
117
|
+
| `htmlSpan(className?)` | `<span class="mention" data-mention-id="uuid">@Vps-audi</span>` |
|
|
118
|
+
|
|
119
|
+
Pick by use case:
|
|
120
|
+
|
|
121
|
+
- **`plainAt`** — chat composers feeding an LLM that reads `@<label>` natively.
|
|
122
|
+
- **`customUri`** — chat that also wants machine-readable IDs in `href` for downstream parsing (e.g. `@[Vps-audi](cmdop://machine/uuid)`).
|
|
123
|
+
- **`markdownLink`** — clickable mentions linking to a profile / detail page.
|
|
124
|
+
- **`slackStyle`** — interop with Slack-like backends that resolve `<@id>` server-side.
|
|
125
|
+
- **`htmlSpan`** — markdown consumers that allow inline HTML and want chip styling baked in.
|
|
126
|
+
- **`plainLabel`** — bare display string (no `@`), e.g. for `/`-commands or non-mention triggers.
|
|
127
|
+
|
|
128
|
+
### Custom renderer
|
|
129
|
+
|
|
130
|
+
The signature is just `(attrs: MentionAttrs) => string`:
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
import type { MentionMarkdownRenderer } from '@djangocfg/ui-tools';
|
|
134
|
+
|
|
135
|
+
const renderMention: MentionMarkdownRenderer = ({ id, label }) =>
|
|
136
|
+
`{{user:${id}|${label}}}`;
|
|
137
|
+
|
|
138
|
+
<MarkdownEditor mentions={{ items, renderMarkdown: renderMention }} ... />
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Either `id` or `label` may be empty strings if upstream config didn't populate them — fall back accordingly. Returning `''` drops the mention from the output.
|
|
142
|
+
|
|
143
|
+
> Mentions are write-only: the markdown isn't parsed back into mention nodes on `setContent`. After submit/reset, the editor receives a plain string — fine for chat composers.
|
|
65
144
|
|
|
66
145
|
## Dependencies
|
|
67
146
|
|
|
68
|
-
All Tiptap packages
|
|
147
|
+
All Tiptap packages and `@floating-ui/dom` are direct dependencies — no extra installs needed.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ReactRenderer } from '@tiptap/react';
|
|
2
2
|
import type { SuggestionOptions } from '@tiptap/suggestion';
|
|
3
|
+
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
|
3
4
|
import { MentionList, type MentionListRef } from './MentionList';
|
|
4
5
|
import type { MentionItem, MentionConfig } from './types';
|
|
5
6
|
|
|
@@ -21,6 +22,47 @@ export function createMentionSuggestion(
|
|
|
21
22
|
render: () => {
|
|
22
23
|
let component: ReactRenderer<MentionListRef> | null = null;
|
|
23
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
|
+
const updatePosition = () => {
|
|
40
|
+
if (!popup) return;
|
|
41
|
+
const virtualEl = buildVirtualElement();
|
|
42
|
+
void computePosition(virtualEl, popup, {
|
|
43
|
+
placement: 'bottom-start',
|
|
44
|
+
middleware: [
|
|
45
|
+
offset(4),
|
|
46
|
+
flip({ fallbackPlacements: ['top-start'] }),
|
|
47
|
+
shift({ padding: 8 }),
|
|
48
|
+
],
|
|
49
|
+
}).then(({ x, y }) => {
|
|
50
|
+
if (!popup) return;
|
|
51
|
+
// transform is more performant than top/left and avoids
|
|
52
|
+
// sub-pixel layout thrash during scroll/resize.
|
|
53
|
+
popup.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const teardown = () => {
|
|
58
|
+
cleanupAutoUpdate?.();
|
|
59
|
+
cleanupAutoUpdate = null;
|
|
60
|
+
popup?.remove();
|
|
61
|
+
popup = null;
|
|
62
|
+
component?.destroy();
|
|
63
|
+
component = null;
|
|
64
|
+
getReferenceRect = null;
|
|
65
|
+
};
|
|
24
66
|
|
|
25
67
|
return {
|
|
26
68
|
onStart: (props) => {
|
|
@@ -35,16 +77,17 @@ export function createMentionSuggestion(
|
|
|
35
77
|
});
|
|
36
78
|
|
|
37
79
|
popup = document.createElement('div');
|
|
38
|
-
|
|
80
|
+
// top/left at 0; actual position is applied via transform by computePosition.
|
|
81
|
+
popup.style.cssText = 'position: absolute; top: 0; left: 0; z-index: 99999;';
|
|
39
82
|
popup.appendChild(component.element);
|
|
83
|
+
document.body.appendChild(popup);
|
|
40
84
|
|
|
41
|
-
|
|
42
|
-
if (rect) {
|
|
43
|
-
popup.style.top = `${rect.bottom + window.scrollY + 4}px`;
|
|
44
|
-
popup.style.left = `${rect.left + window.scrollX}px`;
|
|
45
|
-
}
|
|
85
|
+
getReferenceRect = () => props.clientRect?.() ?? null;
|
|
46
86
|
|
|
47
|
-
|
|
87
|
+
// autoUpdate handles scroll, resize, ancestor scroll/resize, layout shifts.
|
|
88
|
+
// It calls updatePosition synchronously on registration too — no manual first call needed.
|
|
89
|
+
const virtualEl = buildVirtualElement();
|
|
90
|
+
cleanupAutoUpdate = autoUpdate(virtualEl, popup, updatePosition);
|
|
48
91
|
},
|
|
49
92
|
|
|
50
93
|
onUpdate: (props) => {
|
|
@@ -55,25 +98,21 @@ export function createMentionSuggestion(
|
|
|
55
98
|
},
|
|
56
99
|
});
|
|
57
100
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
popup.style.left = `${rect.left + window.scrollX}px`;
|
|
62
|
-
}
|
|
101
|
+
// Refresh reference accessor so autoUpdate sees the new caret rect.
|
|
102
|
+
getReferenceRect = () => props.clientRect?.() ?? null;
|
|
103
|
+
updatePosition();
|
|
63
104
|
},
|
|
64
105
|
|
|
65
106
|
onKeyDown: (props) => {
|
|
66
107
|
if (props.event.key === 'Escape') {
|
|
67
|
-
|
|
68
|
-
component?.destroy();
|
|
108
|
+
teardown();
|
|
69
109
|
return true;
|
|
70
110
|
}
|
|
71
111
|
return component?.ref?.onKeyDown(props.event as unknown as React.KeyboardEvent) ?? false;
|
|
72
112
|
},
|
|
73
113
|
|
|
74
114
|
onExit: () => {
|
|
75
|
-
|
|
76
|
-
component?.destroy();
|
|
115
|
+
teardown();
|
|
77
116
|
},
|
|
78
117
|
};
|
|
79
118
|
},
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export { MarkdownEditor } from './MarkdownEditor';
|
|
2
2
|
export type { MarkdownEditorProps } from './MarkdownEditor';
|
|
3
|
-
export type {
|
|
3
|
+
export type {
|
|
4
|
+
MentionItem,
|
|
5
|
+
MentionConfig,
|
|
6
|
+
MentionAttrs,
|
|
7
|
+
MentionMarkdownRenderer,
|
|
8
|
+
} from './types';
|
|
9
|
+
export { mentionPresets } from './mentionPresets';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline-assertion smoke test for mentionPresets.
|
|
3
|
+
*
|
|
4
|
+
* The `@djangocfg/ui-tools` package has no test runner configured
|
|
5
|
+
* (`package.json` only has build / clean / dev / playground / check).
|
|
6
|
+
* Rather than introduce vitest just for this, we keep the assertions
|
|
7
|
+
* inline and runnable as a one-shot Node script.
|
|
8
|
+
*
|
|
9
|
+
* Run:
|
|
10
|
+
* cd packages/ui-tools
|
|
11
|
+
* npx tsx src/tools/MarkdownEditor/mentionPresets.test.ts
|
|
12
|
+
*
|
|
13
|
+
* Or just trust `npm run check` (tsc --noEmit) to catch type drift —
|
|
14
|
+
* the file is excluded from tsup's `entry`, so it never ships in `dist`.
|
|
15
|
+
*/
|
|
16
|
+
import { mentionPresets } from './mentionPresets';
|
|
17
|
+
|
|
18
|
+
let failures = 0;
|
|
19
|
+
const eq = (label: string, got: string, want: string) => {
|
|
20
|
+
const ok = got === want;
|
|
21
|
+
if (!ok) failures += 1;
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
|
|
24
|
+
if (!ok) {
|
|
25
|
+
// eslint-disable-next-line no-console
|
|
26
|
+
console.log(` got: ${JSON.stringify(got)}`);
|
|
27
|
+
// eslint-disable-next-line no-console
|
|
28
|
+
console.log(` want: ${JSON.stringify(want)}`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const includes = (label: string, got: string, needle: string) => {
|
|
32
|
+
const ok = got.includes(needle);
|
|
33
|
+
if (!ok) failures += 1;
|
|
34
|
+
// eslint-disable-next-line no-console
|
|
35
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
|
|
36
|
+
if (!ok) {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.log(` got: ${JSON.stringify(got)}`);
|
|
39
|
+
// eslint-disable-next-line no-console
|
|
40
|
+
console.log(` needle: ${JSON.stringify(needle)}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ── plainAt ──
|
|
45
|
+
eq(
|
|
46
|
+
'plainAt label present',
|
|
47
|
+
mentionPresets.plainAt({ label: 'Vps-audi', id: 'uuid' }),
|
|
48
|
+
'@Vps-audi',
|
|
49
|
+
);
|
|
50
|
+
eq(
|
|
51
|
+
'plainAt empty label falls back to id',
|
|
52
|
+
mentionPresets.plainAt({ label: '', id: 'uuid' }),
|
|
53
|
+
'@uuid',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ── plainLabel ──
|
|
57
|
+
eq(
|
|
58
|
+
'plainLabel returns label as-is',
|
|
59
|
+
mentionPresets.plainLabel({ label: 'X', id: '1' }),
|
|
60
|
+
'X',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// ── markdownLink ──
|
|
64
|
+
{
|
|
65
|
+
const link = mentionPresets.markdownLink('https://x.com/u/');
|
|
66
|
+
const out = link({ label: 'X Y', id: '1' });
|
|
67
|
+
includes('markdownLink contains label text', out, 'X Y');
|
|
68
|
+
includes('markdownLink contains encoded id', out, 'https://x.com/u/1');
|
|
69
|
+
includes('markdownLink wraps with [@...](', out, '[@X Y](https://x.com/u/1)');
|
|
70
|
+
|
|
71
|
+
const escaped = link({ label: 'A_B*C[D]', id: 'id 2' });
|
|
72
|
+
includes('markdownLink escapes label specials', escaped, '\\_');
|
|
73
|
+
includes('markdownLink escapes brackets', escaped, '\\[');
|
|
74
|
+
includes('markdownLink escapes asterisk', escaped, '\\*');
|
|
75
|
+
includes('markdownLink encodes id space', escaped, 'id%202');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── customUri ──
|
|
79
|
+
eq(
|
|
80
|
+
'customUri Notion-style',
|
|
81
|
+
mentionPresets.customUri('cmdop', 'machine')({ label: 'My Box', id: 'uuid' }),
|
|
82
|
+
'@[My Box](cmdop://machine/uuid)',
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// ── slackStyle ──
|
|
86
|
+
eq(
|
|
87
|
+
'slackStyle drops label, emits <@id>',
|
|
88
|
+
mentionPresets.slackStyle({ label: 'X', id: 'U123' }),
|
|
89
|
+
'<@U123>',
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// ── htmlSpan ──
|
|
93
|
+
{
|
|
94
|
+
const out = mentionPresets.htmlSpan('m')({ label: 'X', id: '1' });
|
|
95
|
+
includes('htmlSpan uses class', out, 'class="m"');
|
|
96
|
+
includes('htmlSpan exposes data-mention-id', out, 'data-mention-id="1"');
|
|
97
|
+
includes('htmlSpan body shows label with @', out, '@X');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (failures > 0) {
|
|
101
|
+
// eslint-disable-next-line no-console
|
|
102
|
+
console.error(`\n${failures} assertion(s) failed.`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
} else {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.log('\nAll mentionPresets assertions passed.');
|
|
107
|
+
}
|