@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.241",
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.241",
89
- "@djangocfg/ui-core": "^2.1.241",
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
- "@rpldy/uploady": "^1.8.5",
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.241",
131
+ "@djangocfg/i18n": "^2.1.242",
124
132
  "@djangocfg/playground": "workspace:*",
125
- "@djangocfg/typescript-config": "^2.1.241",
126
- "@djangocfg/ui-core": "^2.1.241",
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
+ }
@@ -0,0 +1,3 @@
1
+ export { MarkdownEditor } from './MarkdownEditor';
2
+ export type { MarkdownEditorProps } from './MarkdownEditor';
3
+ export type { MentionItem, MentionConfig } from './types';