@djangocfg/ui-tools 2.1.291 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.291",
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",
@@ -91,8 +91,8 @@
91
91
  "check": "tsc --noEmit"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/i18n": "^2.1.291",
95
- "@djangocfg/ui-core": "^2.1.291",
94
+ "@djangocfg/i18n": "^2.1.292",
95
+ "@djangocfg/ui-core": "^2.1.292",
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.291",
143
+ "@djangocfg/i18n": "^2.1.292",
143
144
  "@djangocfg/playground": "workspace:*",
144
- "@djangocfg/typescript-config": "^2.1.291",
145
- "@djangocfg/ui-core": "^2.1.291",
145
+ "@djangocfg/typescript-config": "^2.1.292",
146
+ "@djangocfg/ui-core": "^2.1.292",
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 { useState } from 'react';
4
- import type { MentionConfig } from './types';
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 type { MentionConfig } from './types';
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.configure({
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` | — | @mention autocomplete config |
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 are included as direct dependencies — no extra installs needed.
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
- popup.style.cssText = 'position: absolute; z-index: 99999;';
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
- const rect = props.clientRect?.();
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
- document.body.appendChild(popup);
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
- const rect = props.clientRect?.();
59
- if (rect && popup) {
60
- popup.style.top = `${rect.bottom + window.scrollY + 4}px`;
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
- popup?.remove();
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
- popup?.remove();
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 { MentionItem, MentionConfig } from './types';
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: off. */
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