@djangocfg/ui-tools 2.1.291 → 2.1.293
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-PLWQJBGU.cjs} +10 -4
- package/dist/DocsLayout-PLWQJBGU.cjs.map +1 -0
- package/dist/{DocsLayout-JPXFUKAR.mjs → DocsLayout-XB55R7YG.mjs} +11 -5
- package/dist/DocsLayout-XB55R7YG.mjs.map +1 -0
- package/dist/index.cjs +73 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -6
- package/dist/index.d.ts +66 -6
- package/dist/index.mjs +73 -19
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -6
- package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +108 -2
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +46 -2
- 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/Sidebar/EndpointRow.tsx +12 -1
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SidebarBody.tsx +1 -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.293",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -91,8 +91,8 @@
|
|
|
91
91
|
"check": "tsc --noEmit"
|
|
92
92
|
},
|
|
93
93
|
"peerDependencies": {
|
|
94
|
-
"@djangocfg/i18n": "^2.1.
|
|
95
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/i18n": "^2.1.293",
|
|
95
|
+
"@djangocfg/ui-core": "^2.1.293",
|
|
96
96
|
"consola": "^3.4.2",
|
|
97
97
|
"lodash-es": "^4.18.1",
|
|
98
98
|
"lucide-react": "^0.545.0",
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"zustand": "^5.0.0"
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
105
|
+
"@floating-ui/dom": "^1.7.4",
|
|
105
106
|
"@rjsf/core": "^6.1.2",
|
|
106
107
|
"@rjsf/utils": "^6.1.2",
|
|
107
108
|
"@rjsf/validator-ajv8": "^6.1.2",
|
|
@@ -139,10 +140,10 @@
|
|
|
139
140
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
140
141
|
},
|
|
141
142
|
"devDependencies": {
|
|
142
|
-
"@djangocfg/i18n": "^2.1.
|
|
143
|
+
"@djangocfg/i18n": "^2.1.293",
|
|
143
144
|
"@djangocfg/playground": "workspace:*",
|
|
144
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
145
|
-
"@djangocfg/ui-core": "^2.1.
|
|
145
|
+
"@djangocfg/typescript-config": "^2.1.293",
|
|
146
|
+
"@djangocfg/ui-core": "^2.1.293",
|
|
146
147
|
"@types/lodash-es": "^4.17.12",
|
|
147
148
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
148
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 ──
|
|
@@ -117,8 +118,51 @@ export function MarkdownEditor({
|
|
|
117
118
|
];
|
|
118
119
|
|
|
119
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;
|
|
120
151
|
exts.push(
|
|
121
|
-
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({
|
|
122
166
|
HTMLAttributes: { class: 'markdown-mention' },
|
|
123
167
|
suggestion: createMentionSuggestion(mentions),
|
|
124
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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MentionMarkdownRenderer } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Escape characters that have meaning in markdown link/inline contexts.
|
|
5
|
+
* Conservative — covers the chars that would break `[text](url)` and
|
|
6
|
+
* inline emphasis when a label contains them.
|
|
7
|
+
*/
|
|
8
|
+
const escapeMd = (s: string): string => s.replace(/([\\\[\]()_*~`])/g, '\\$1');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Built-in serializers for the `MentionConfig.renderMarkdown` callback.
|
|
12
|
+
*
|
|
13
|
+
* Pick one based on what consumes the markdown:
|
|
14
|
+
*
|
|
15
|
+
* - LLM / chat composer → `plainAt` (the default)
|
|
16
|
+
* - Plain text export → `plainLabel`
|
|
17
|
+
* - Web app with deep-link → `markdownLink(baseUrl)`
|
|
18
|
+
* - Notion / Linear-style → `customUri(scheme, kind)`
|
|
19
|
+
* - Slack-style id refs → `slackStyle`
|
|
20
|
+
* - HTML-allowing renderer → `htmlSpan(className?)`
|
|
21
|
+
*
|
|
22
|
+
* Or pass a custom function — the type is just `(attrs) => string`.
|
|
23
|
+
*/
|
|
24
|
+
export const mentionPresets = {
|
|
25
|
+
/** "@Label" — default, ideal for chat where LLMs read the text. */
|
|
26
|
+
plainAt: (({ label, id }) => `@${label || id}`) as MentionMarkdownRenderer,
|
|
27
|
+
|
|
28
|
+
/** "Label" — bare label, no @ prefix. */
|
|
29
|
+
plainLabel: (({ label, id }) => label || id) as MentionMarkdownRenderer,
|
|
30
|
+
|
|
31
|
+
/** "[@Label](baseUrl/id)" — clickable markdown link. */
|
|
32
|
+
markdownLink: (baseUrl: string): MentionMarkdownRenderer =>
|
|
33
|
+
({ label, id }) =>
|
|
34
|
+
`[@${escapeMd(label || id)}](${baseUrl}${encodeURIComponent(id)})`,
|
|
35
|
+
|
|
36
|
+
/** "@[Label](scheme://kind/id)" — Notion / Linear-style custom URI. */
|
|
37
|
+
customUri: (scheme: string, kind: string): MentionMarkdownRenderer =>
|
|
38
|
+
({ label, id }) =>
|
|
39
|
+
`@[${escapeMd(label || id)}](${scheme}://${kind}/${encodeURIComponent(id)})`,
|
|
40
|
+
|
|
41
|
+
/** "<@id>" — Slack-style id-only reference (label dropped — receivers resolve it). */
|
|
42
|
+
slackStyle: (({ id }) => `<@${id}>`) as MentionMarkdownRenderer,
|
|
43
|
+
|
|
44
|
+
/** Inline HTML span — for products that consume markdown with raw HTML allowed. */
|
|
45
|
+
htmlSpan:
|
|
46
|
+
(className = 'mention'): MentionMarkdownRenderer =>
|
|
47
|
+
({ label, id }) =>
|
|
48
|
+
`<span class="${className}" data-mention-id="${encodeURIComponent(id)}">@${escapeMd(label || id)}</span>`,
|
|
49
|
+
};
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { ReactNode } from 'react';
|
|
2
|
-
|
|
3
1
|
/** Item that can be mentioned via @ trigger */
|
|
4
2
|
export interface MentionItem {
|
|
5
3
|
id: string;
|
|
@@ -8,6 +6,27 @@ export interface MentionItem {
|
|
|
8
6
|
thumbnail?: string;
|
|
9
7
|
}
|
|
10
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Attributes available when rendering a mention to markdown.
|
|
11
|
+
*
|
|
12
|
+
* Same shape Tiptap stores on the `mention` node — `id` is the stable
|
|
13
|
+
* identifier the suggestion popover injected, `label` is the human text
|
|
14
|
+
* shown to the user. Either field may be empty if the upstream config
|
|
15
|
+
* never populated it; renderers should fall back accordingly.
|
|
16
|
+
*/
|
|
17
|
+
export interface MentionAttrs {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Function that converts a mention node into its markdown serialization.
|
|
24
|
+
*
|
|
25
|
+
* The returned string is what `@tiptap/markdown` writes into the markdown
|
|
26
|
+
* output of `MarkdownEditor.getMarkdown()`.
|
|
27
|
+
*/
|
|
28
|
+
export type MentionMarkdownRenderer = (attrs: MentionAttrs) => string;
|
|
29
|
+
|
|
11
30
|
/** Mention configuration */
|
|
12
31
|
export interface MentionConfig {
|
|
13
32
|
/** Trigger character (default: '@') */
|
|
@@ -16,4 +35,16 @@ export interface MentionConfig {
|
|
|
16
35
|
items: MentionItem[];
|
|
17
36
|
/** Max dropdown items (default: 5) */
|
|
18
37
|
maxItems?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Custom serializer for mentions when `MarkdownEditor.getMarkdown()` runs.
|
|
40
|
+
*
|
|
41
|
+
* Defaults to `mentionPresets.plainAt` which yields `@<label>` (or `@<id>`
|
|
42
|
+
* when `label` is missing). Use one of `mentionPresets`, or supply your
|
|
43
|
+
* own function for full control over how mentions appear in the output
|
|
44
|
+
* markdown string.
|
|
45
|
+
*
|
|
46
|
+
* Note: this only affects the *markdown* output. `editor.getText()` and
|
|
47
|
+
* the rendered DOM still go through `renderText` (`@<label>` by default).
|
|
48
|
+
*/
|
|
49
|
+
renderMarkdown?: MentionMarkdownRenderer;
|
|
19
50
|
}
|
|
@@ -251,7 +251,8 @@ interface PlaygroundConfig {
|
|
|
251
251
|
* the longread as top-level sections. */
|
|
252
252
|
schemaGrouping?: 'selector' | 'sections';
|
|
253
253
|
/** Sync the active endpoint anchor to ``window.location.hash`` as
|
|
254
|
-
* the user scrolls (sections mode). Default:
|
|
254
|
+
* the user scrolls (sections mode). Default: on. Pass ``false`` to
|
|
255
|
+
* opt out if the host page manages the hash itself. */
|
|
255
256
|
urlSync?: boolean;
|
|
256
257
|
}
|
|
257
258
|
|