@djangocfg/ui-tools 2.1.241 → 2.1.242
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 +2 -1
- package/dist/index.cjs +221 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +162 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +33 -1
- package/dist/index.d.ts +33 -1
- package/dist/index.mjs +218 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -10
- package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +119 -0
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +174 -0
- package/src/tools/MarkdownEditor/MentionList.tsx +73 -0
- package/src/tools/MarkdownEditor/README.md +68 -0
- package/src/tools/MarkdownEditor/createMentionSuggestion.ts +81 -0
- package/src/tools/MarkdownEditor/index.ts +3 -0
- package/src/tools/MarkdownEditor/styles.css +191 -0
- package/src/tools/MarkdownEditor/types.ts +19 -0
- package/src/tools/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.242",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -85,26 +85,34 @@
|
|
|
85
85
|
"check": "tsc --noEmit"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
|
-
"@djangocfg/i18n": "^2.1.
|
|
89
|
-
"@djangocfg/ui-core": "^2.1.
|
|
88
|
+
"@djangocfg/i18n": "^2.1.242",
|
|
89
|
+
"@djangocfg/ui-core": "^2.1.242",
|
|
90
|
+
"consola": "^3.4.2",
|
|
90
91
|
"lucide-react": "^0.545.0",
|
|
91
92
|
"react": "^19.1.0",
|
|
92
93
|
"react-dom": "^19.1.0",
|
|
93
94
|
"tailwindcss": "^4.1.18",
|
|
94
|
-
"zustand": "^5.0.0"
|
|
95
|
-
"consola": "^3.4.2"
|
|
95
|
+
"zustand": "^5.0.0"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@
|
|
98
|
+
"@tiptap/core": "^3.20.1",
|
|
99
|
+
"@tiptap/react": "^3.20.1",
|
|
100
|
+
"@tiptap/starter-kit": "^3.20.1",
|
|
101
|
+
"@tiptap/extension-placeholder": "^3.20.1",
|
|
102
|
+
"@tiptap/extension-mention": "^3.20.1",
|
|
103
|
+
"@tiptap/suggestion": "^3.20.1",
|
|
104
|
+
"@tiptap/markdown": "^3.20.1",
|
|
105
|
+
"@tiptap/pm": "^3.20.1",
|
|
99
106
|
"@rjsf/core": "^6.1.2",
|
|
100
107
|
"@rjsf/utils": "^6.1.2",
|
|
101
108
|
"@rjsf/validator-ajv8": "^6.1.2",
|
|
109
|
+
"@rpldy/uploady": "^1.8.5",
|
|
102
110
|
"@vidstack/react": "next",
|
|
103
111
|
"@wavesurfer/react": "^1.0.12",
|
|
104
|
-
"monaco-editor": "^0.55.1",
|
|
105
112
|
"maplibre-gl": "^4.7.1",
|
|
106
113
|
"media-icons": "next",
|
|
107
114
|
"mermaid": "^11.12.0",
|
|
115
|
+
"monaco-editor": "^0.55.1",
|
|
108
116
|
"prism-react-renderer": "^2.4.1",
|
|
109
117
|
"react-json-tree": "^0.20.0",
|
|
110
118
|
"react-lottie-player": "^2.1.0",
|
|
@@ -120,10 +128,10 @@
|
|
|
120
128
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
121
129
|
},
|
|
122
130
|
"devDependencies": {
|
|
123
|
-
"@djangocfg/i18n": "^2.1.
|
|
131
|
+
"@djangocfg/i18n": "^2.1.242",
|
|
124
132
|
"@djangocfg/playground": "workspace:*",
|
|
125
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
126
|
-
"@djangocfg/ui-core": "^2.1.
|
|
133
|
+
"@djangocfg/typescript-config": "^2.1.242",
|
|
134
|
+
"@djangocfg/ui-core": "^2.1.242",
|
|
127
135
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
128
136
|
"@types/node": "^24.7.2",
|
|
129
137
|
"@types/react": "^19.1.0",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { defineStory } from '@djangocfg/playground';
|
|
2
|
+
import { MarkdownEditor } from './MarkdownEditor';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { MentionConfig } from './types';
|
|
5
|
+
|
|
6
|
+
export default defineStory({
|
|
7
|
+
title: 'Tools/Markdown Editor',
|
|
8
|
+
component: MarkdownEditor,
|
|
9
|
+
description: 'WYSIWYG markdown editor with Tiptap. Supports headings, lists, mentions, and more.',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const SAMPLE_MARKDOWN = `# Character Bio
|
|
13
|
+
|
|
14
|
+
**Type:** Maltipoo (apricot coat)
|
|
15
|
+
**Age:** 3 years
|
|
16
|
+
|
|
17
|
+
## Visual Design
|
|
18
|
+
|
|
19
|
+
Medium-small Maltipoo, compact and agile.
|
|
20
|
+
|
|
21
|
+
- Sharp, intelligent eyes (dark brown)
|
|
22
|
+
- Simple collar (hides micro-gear)
|
|
23
|
+
- Expressions shift quickly
|
|
24
|
+
|
|
25
|
+
## Personality
|
|
26
|
+
|
|
27
|
+
> Pixar Note: Her face must carry duality.
|
|
28
|
+
|
|
29
|
+
1. Emotionally guarded
|
|
30
|
+
2. Independent and decisive
|
|
31
|
+
3. Dry sense of humor
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const MENTION_ITEMS: MentionConfig = {
|
|
35
|
+
items: [
|
|
36
|
+
{ id: '1', label: 'Alice', description: 'Protagonist', thumbnail: 'https://i.pravatar.cc/48?u=alice' },
|
|
37
|
+
{ id: '2', label: 'Bob', description: 'Antagonist', thumbnail: 'https://i.pravatar.cc/48?u=bob' },
|
|
38
|
+
{ id: '3', label: 'Charlie', description: 'Side character', thumbnail: 'https://i.pravatar.cc/48?u=charlie' },
|
|
39
|
+
{ id: '4', label: 'Diana', description: 'Narrator' },
|
|
40
|
+
{ id: '5', label: 'Eve', description: 'Mystery character' },
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function Default() {
|
|
45
|
+
const [value, setValue] = useState(SAMPLE_MARKDOWN);
|
|
46
|
+
return (
|
|
47
|
+
<div style={{ maxWidth: 700 }}>
|
|
48
|
+
<MarkdownEditor value={value} onChange={setValue} />
|
|
49
|
+
<RawPreview value={value} />
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function WithMentions() {
|
|
55
|
+
const [value, setValue] = useState('Hello @Alice! This scene features @Bob too.\n\nType @ to mention characters.');
|
|
56
|
+
const [ids, setIds] = useState<string[]>([]);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div style={{ maxWidth: 700 }}>
|
|
60
|
+
<MarkdownEditor
|
|
61
|
+
value={value}
|
|
62
|
+
onChange={setValue}
|
|
63
|
+
mentions={MENTION_ITEMS}
|
|
64
|
+
onMentionIdsChange={setIds}
|
|
65
|
+
placeholder="Describe the scene... Use @ to mention characters"
|
|
66
|
+
/>
|
|
67
|
+
{ids.length > 0 && (
|
|
68
|
+
<div style={{ marginTop: 8, fontSize: 12, opacity: 0.6 }}>
|
|
69
|
+
Mentioned IDs: {ids.join(', ')}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
<RawPreview value={value} />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function Empty() {
|
|
78
|
+
const [value, setValue] = useState('');
|
|
79
|
+
return (
|
|
80
|
+
<div style={{ maxWidth: 600 }}>
|
|
81
|
+
<MarkdownEditor value={value} onChange={setValue} placeholder="Start writing..." />
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function Disabled() {
|
|
87
|
+
return (
|
|
88
|
+
<div style={{ maxWidth: 600 }}>
|
|
89
|
+
<MarkdownEditor value="This editor is **read-only**." onChange={() => {}} disabled />
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function NoToolbar() {
|
|
95
|
+
const [value, setValue] = useState('Plain text without toolbar.\n\nStill supports **markdown** shortcuts.');
|
|
96
|
+
return (
|
|
97
|
+
<div style={{ maxWidth: 600 }}>
|
|
98
|
+
<MarkdownEditor value={value} onChange={setValue} showToolbar={false} />
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function Compact() {
|
|
104
|
+
const [value, setValue] = useState('Short note');
|
|
105
|
+
return (
|
|
106
|
+
<div style={{ maxWidth: 400 }}>
|
|
107
|
+
<MarkdownEditor value={value} onChange={setValue} minHeight={60} placeholder="Quick note..." />
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function RawPreview({ value }: { value: string }) {
|
|
113
|
+
return (
|
|
114
|
+
<details style={{ marginTop: 16 }}>
|
|
115
|
+
<summary style={{ cursor: 'pointer', opacity: 0.5, fontSize: 12 }}>Raw markdown</summary>
|
|
116
|
+
<pre style={{ fontSize: 11, opacity: 0.6, whiteSpace: 'pre-wrap', marginTop: 8 }}>{value}</pre>
|
|
117
|
+
</details>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
5
|
+
import Placeholder from '@tiptap/extension-placeholder';
|
|
6
|
+
import Mention from '@tiptap/extension-mention';
|
|
7
|
+
import { Markdown } from '@tiptap/markdown';
|
|
8
|
+
import type { AnyExtension } from '@tiptap/core';
|
|
9
|
+
import { useEffect, useRef, useMemo } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
Bold, Italic, Strikethrough, Heading1, Heading2, Heading3,
|
|
12
|
+
List, ListOrdered, Quote, Minus, Code, type LucideIcon,
|
|
13
|
+
} from 'lucide-react';
|
|
14
|
+
import { createMentionSuggestion } from './createMentionSuggestion';
|
|
15
|
+
import type { MentionConfig } from './types';
|
|
16
|
+
import './styles.css';
|
|
17
|
+
|
|
18
|
+
// ── Helpers ──
|
|
19
|
+
|
|
20
|
+
interface MarkdownManager {
|
|
21
|
+
serialize: (json: Record<string, unknown>) => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getMarkdown(editor: Editor): string {
|
|
25
|
+
const storage = editor.storage.markdown as { manager?: MarkdownManager } | undefined;
|
|
26
|
+
if (!storage?.manager) return editor.getText();
|
|
27
|
+
return storage.manager.serialize(editor.getJSON());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractMentionIds(editor: Editor): string[] {
|
|
31
|
+
const ids: string[] = [];
|
|
32
|
+
editor.state.doc.descendants((node) => {
|
|
33
|
+
if (node.type.name === 'mention' && node.attrs.id) {
|
|
34
|
+
ids.push(node.attrs.id as string);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return [...new Set(ids)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Types ──
|
|
41
|
+
|
|
42
|
+
export interface MarkdownEditorProps {
|
|
43
|
+
value: string;
|
|
44
|
+
onChange: (value: string) => void;
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
minHeight?: number;
|
|
47
|
+
className?: string;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
showToolbar?: boolean;
|
|
50
|
+
/** @mention autocomplete config */
|
|
51
|
+
mentions?: MentionConfig;
|
|
52
|
+
/** Called when mentioned IDs change */
|
|
53
|
+
onMentionIdsChange?: (ids: string[]) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Component ──
|
|
57
|
+
|
|
58
|
+
export function MarkdownEditor({
|
|
59
|
+
value,
|
|
60
|
+
onChange,
|
|
61
|
+
placeholder = 'Write markdown...',
|
|
62
|
+
minHeight = 120,
|
|
63
|
+
className = '',
|
|
64
|
+
disabled = false,
|
|
65
|
+
showToolbar = true,
|
|
66
|
+
mentions,
|
|
67
|
+
onMentionIdsChange,
|
|
68
|
+
}: MarkdownEditorProps) {
|
|
69
|
+
const isExternalUpdate = useRef(false);
|
|
70
|
+
|
|
71
|
+
const extensions = useMemo(() => {
|
|
72
|
+
const exts: AnyExtension[] = [
|
|
73
|
+
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
|
74
|
+
Placeholder.configure({ placeholder }),
|
|
75
|
+
Markdown,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
if (mentions) {
|
|
79
|
+
exts.push(
|
|
80
|
+
Mention.configure({
|
|
81
|
+
HTMLAttributes: { class: 'markdown-mention' },
|
|
82
|
+
suggestion: createMentionSuggestion(mentions),
|
|
83
|
+
renderText: ({ node }) => `@${node.attrs.label}`,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return exts;
|
|
89
|
+
}, [placeholder, mentions]);
|
|
90
|
+
|
|
91
|
+
const editor = useEditor({
|
|
92
|
+
immediatelyRender: false,
|
|
93
|
+
editable: !disabled,
|
|
94
|
+
extensions,
|
|
95
|
+
content: value,
|
|
96
|
+
onUpdate: ({ editor }) => {
|
|
97
|
+
if (isExternalUpdate.current) return;
|
|
98
|
+
onChange(getMarkdown(editor));
|
|
99
|
+
|
|
100
|
+
if (onMentionIdsChange) {
|
|
101
|
+
onMentionIdsChange(extractMentionIds(editor));
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
editorProps: {
|
|
105
|
+
attributes: {
|
|
106
|
+
class: 'markdown-editor-content focus:outline-none text-sm',
|
|
107
|
+
style: `min-height: ${minHeight}px`,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!editor) return;
|
|
114
|
+
const current = getMarkdown(editor);
|
|
115
|
+
if (current !== value) {
|
|
116
|
+
isExternalUpdate.current = true;
|
|
117
|
+
editor.commands.setContent(value);
|
|
118
|
+
isExternalUpdate.current = false;
|
|
119
|
+
}
|
|
120
|
+
}, [value, editor]);
|
|
121
|
+
|
|
122
|
+
const wrapperClass = `markdown-editor rounded-md border border-input bg-background ${disabled ? 'opacity-60' : ''} ${className}`.trim();
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={wrapperClass}>
|
|
126
|
+
{showToolbar && editor && <MarkdownToolbar editor={editor} />}
|
|
127
|
+
<div className="px-3 py-2">
|
|
128
|
+
<EditorContent editor={editor} />
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Toolbar ──
|
|
135
|
+
|
|
136
|
+
interface ToolbarItem {
|
|
137
|
+
icon: LucideIcon;
|
|
138
|
+
action: () => void;
|
|
139
|
+
active: boolean;
|
|
140
|
+
title: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function MarkdownToolbar({ editor }: { editor: Editor }) {
|
|
144
|
+
const items = useMemo<(ToolbarItem | null)[]>(() => [
|
|
145
|
+
{ icon: Bold, title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold') },
|
|
146
|
+
{ icon: Italic, title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic') },
|
|
147
|
+
{ icon: Strikethrough, title: 'Strike', action: () => editor.chain().focus().toggleStrike().run(), active: editor.isActive('strike') },
|
|
148
|
+
{ icon: Code, title: 'Code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code') },
|
|
149
|
+
null,
|
|
150
|
+
{ icon: Heading1, title: 'H1', action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), active: editor.isActive('heading', { level: 1 }) },
|
|
151
|
+
{ icon: Heading2, title: 'H2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }) },
|
|
152
|
+
{ icon: Heading3, title: 'H3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }) },
|
|
153
|
+
null,
|
|
154
|
+
{ icon: List, title: 'Bullet list', action: () => editor.chain().focus().toggleBulletList().run(), active: editor.isActive('bulletList') },
|
|
155
|
+
{ icon: ListOrdered, title: 'Ordered list', action: () => editor.chain().focus().toggleOrderedList().run(), active: editor.isActive('orderedList') },
|
|
156
|
+
{ icon: Quote, title: 'Quote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote') },
|
|
157
|
+
{ icon: Minus, title: 'Divider', action: () => editor.chain().focus().setHorizontalRule().run(), active: false },
|
|
158
|
+
], [editor]);
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-border">
|
|
162
|
+
{items.map((item, i) => {
|
|
163
|
+
if (!item) return <div key={i} className="w-px h-4 bg-border mx-1" />;
|
|
164
|
+
const Icon = item.icon;
|
|
165
|
+
const btnClass = `markdown-toolbar-btn ${item.active ? 'active' : ''}`;
|
|
166
|
+
return (
|
|
167
|
+
<button key={i} type="button" onClick={item.action} title={item.title} className={btnClass}>
|
|
168
|
+
<Icon style={{ width: 14, height: 14 }} />
|
|
169
|
+
</button>
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useImperativeHandle, useState, useCallback, useEffect, type KeyboardEvent } from 'react';
|
|
4
|
+
import type { MentionItem } from './types';
|
|
5
|
+
|
|
6
|
+
export interface MentionListRef {
|
|
7
|
+
onKeyDown: (event: KeyboardEvent) => boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface MentionListProps {
|
|
11
|
+
items: MentionItem[];
|
|
12
|
+
command: (item: MentionItem) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
|
16
|
+
({ items, command }, ref) => {
|
|
17
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
18
|
+
|
|
19
|
+
useEffect(() => setSelectedIndex(0), [items]);
|
|
20
|
+
|
|
21
|
+
const select = useCallback(
|
|
22
|
+
(index: number) => {
|
|
23
|
+
const item = items[index];
|
|
24
|
+
if (item) command(item);
|
|
25
|
+
},
|
|
26
|
+
[items, command],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
useImperativeHandle(ref, () => ({
|
|
30
|
+
onKeyDown: (event: KeyboardEvent) => {
|
|
31
|
+
if (event.key === 'ArrowUp') {
|
|
32
|
+
setSelectedIndex((i) => (i + items.length - 1) % items.length);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (event.key === 'ArrowDown') {
|
|
36
|
+
setSelectedIndex((i) => (i + 1) % items.length);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (event.key === 'Enter') {
|
|
40
|
+
select(selectedIndex);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
if (items.length === 0) return null;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="markdown-mention-list">
|
|
51
|
+
{items.map((item, i) => {
|
|
52
|
+
const isSelected = i === selectedIndex;
|
|
53
|
+
const cls = `markdown-mention-item ${isSelected ? 'selected' : ''}`;
|
|
54
|
+
return (
|
|
55
|
+
<button key={item.id} type="button" className={cls} onClick={() => select(i)}>
|
|
56
|
+
{item.thumbnail && (
|
|
57
|
+
<img src={item.thumbnail} alt="" className="markdown-mention-avatar" />
|
|
58
|
+
)}
|
|
59
|
+
<div className="markdown-mention-info">
|
|
60
|
+
<span className="markdown-mention-name">{item.label}</span>
|
|
61
|
+
{item.description && (
|
|
62
|
+
<span className="markdown-mention-desc">{item.description}</span>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
MentionList.displayName = 'MentionList';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# MarkdownEditor
|
|
2
|
+
|
|
3
|
+
WYSIWYG markdown editor based on Tiptap. Renders markdown visually (headings, lists, blockquotes) while editing, serializes to markdown string.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Visual headings (H1, H2, H3) with proper sizing
|
|
8
|
+
- Bold, italic, strikethrough, inline code
|
|
9
|
+
- Bullet and ordered lists
|
|
10
|
+
- Blockquotes with left border
|
|
11
|
+
- Horizontal rules
|
|
12
|
+
- Toolbar with icon buttons
|
|
13
|
+
- Markdown input/output (stored as plain markdown string)
|
|
14
|
+
- SSR-safe (`immediatelyRender: false`)
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { MarkdownEditor } from '@djangocfg/ui-tools';
|
|
20
|
+
|
|
21
|
+
function MyComponent() {
|
|
22
|
+
const [bio, setBio] = useState('# Hello\n\nThis is **markdown**.');
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<MarkdownEditor
|
|
26
|
+
value={bio}
|
|
27
|
+
onChange={setBio}
|
|
28
|
+
placeholder="Write something..."
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Props
|
|
35
|
+
|
|
36
|
+
| Prop | Type | Default | Description |
|
|
37
|
+
|------|------|---------|-------------|
|
|
38
|
+
| `value` | `string` | — | Markdown string |
|
|
39
|
+
| `onChange` | `(value: string) => void` | — | Called on every change |
|
|
40
|
+
| `placeholder` | `string` | `'Write markdown...'` | Placeholder text |
|
|
41
|
+
| `minHeight` | `number` | `120` | Min height in px |
|
|
42
|
+
| `className` | `string` | — | Additional CSS class |
|
|
43
|
+
| `disabled` | `boolean` | `false` | Read-only mode |
|
|
44
|
+
| `showToolbar` | `boolean` | `true` | Show formatting toolbar |
|
|
45
|
+
| `mentions` | `MentionConfig` | — | @mention autocomplete config |
|
|
46
|
+
| `onMentionIdsChange` | `(ids: string[]) => void` | — | Called when mentioned IDs change |
|
|
47
|
+
|
|
48
|
+
## Mentions
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
<MarkdownEditor
|
|
52
|
+
value={text}
|
|
53
|
+
onChange={setText}
|
|
54
|
+
mentions={{
|
|
55
|
+
items: [
|
|
56
|
+
{ id: '1', label: 'Alice', thumbnail: '/alice.jpg' },
|
|
57
|
+
{ id: '2', label: 'Bob', description: 'Antagonist' },
|
|
58
|
+
],
|
|
59
|
+
}}
|
|
60
|
+
onMentionIdsChange={(ids) => console.log('Mentioned:', ids)}
|
|
61
|
+
/>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Type `@` to trigger autocomplete. Mentions render as inline chips.
|
|
65
|
+
|
|
66
|
+
## Dependencies
|
|
67
|
+
|
|
68
|
+
All Tiptap packages are included as direct dependencies — no extra installs needed.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ReactRenderer } from '@tiptap/react';
|
|
2
|
+
import type { SuggestionOptions } from '@tiptap/suggestion';
|
|
3
|
+
import { MentionList, type MentionListRef } from './MentionList';
|
|
4
|
+
import type { MentionItem, MentionConfig } from './types';
|
|
5
|
+
|
|
6
|
+
export function createMentionSuggestion(
|
|
7
|
+
config: MentionConfig,
|
|
8
|
+
): Omit<SuggestionOptions<MentionItem>, 'editor'> {
|
|
9
|
+
const { maxItems = 5, trigger = '@' } = config;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
char: trigger,
|
|
13
|
+
|
|
14
|
+
items: ({ query }) => {
|
|
15
|
+
const q = query.toLowerCase();
|
|
16
|
+
return config.items
|
|
17
|
+
.filter((item) => item.label.toLowerCase().includes(q))
|
|
18
|
+
.slice(0, maxItems);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
render: () => {
|
|
22
|
+
let component: ReactRenderer<MentionListRef> | null = null;
|
|
23
|
+
let popup: HTMLDivElement | null = null;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
onStart: (props) => {
|
|
27
|
+
component = new ReactRenderer(MentionList, {
|
|
28
|
+
props: {
|
|
29
|
+
items: props.items,
|
|
30
|
+
command: (item: MentionItem) => {
|
|
31
|
+
props.command({ id: item.id, label: item.label });
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
editor: props.editor,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
popup = document.createElement('div');
|
|
38
|
+
popup.style.cssText = 'position: absolute; z-index: 99999;';
|
|
39
|
+
popup.appendChild(component.element);
|
|
40
|
+
|
|
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
|
+
}
|
|
46
|
+
|
|
47
|
+
document.body.appendChild(popup);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
onUpdate: (props) => {
|
|
51
|
+
component?.updateProps({
|
|
52
|
+
items: props.items,
|
|
53
|
+
command: (item: MentionItem) => {
|
|
54
|
+
props.command({ id: item.id, label: item.label });
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
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
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
onKeyDown: (props) => {
|
|
66
|
+
if (props.event.key === 'Escape') {
|
|
67
|
+
popup?.remove();
|
|
68
|
+
component?.destroy();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return component?.ref?.onKeyDown(props.event as unknown as React.KeyboardEvent) ?? false;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
onExit: () => {
|
|
75
|
+
popup?.remove();
|
|
76
|
+
component?.destroy();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|