@djangocfg/ui-tools 2.1.415 → 2.1.416
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/dist/file-icon/index.d.cts +1 -1
- package/dist/file-icon/index.d.ts +1 -1
- package/dist/slots-ClRpIzoh.d.cts +88 -0
- package/dist/slots-ClRpIzoh.d.ts +88 -0
- package/dist/tree/index.cjs +1994 -276
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +717 -72
- package/dist/tree/index.d.ts +717 -72
- package/dist/tree/index.mjs +1984 -279
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +10 -6
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +138 -17
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
- package/src/tools/chat/composer/index.ts +22 -0
- package/src/tools/chat/composer/slash/README.md +187 -0
- package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
- package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
- package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
- package/src/tools/chat/composer/slash/index.ts +44 -0
- package/src/tools/chat/composer/slash/labels.ts +19 -0
- package/src/tools/chat/composer/slash/state.ts +168 -0
- package/src/tools/chat/composer/slash/types.ts +64 -0
- package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
- package/src/tools/chat/composer/types.ts +8 -0
- package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
- package/src/tools/chat/shell/index.ts +6 -0
- package/src/tools/data/Listbox/lazy.tsx +1 -1
- package/src/tools/data/Masonry/lazy.tsx +1 -1
- package/src/tools/data/Timeline/lazy.tsx +1 -1
- package/src/tools/data/Tree/FinderTree.tsx +42 -0
- package/src/tools/data/Tree/README.md +337 -208
- package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
- package/src/tools/data/Tree/TreeRoot.tsx +170 -55
- package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
- package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
- package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
- package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
- package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
- package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
- package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
- package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
- package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
- package/src/tools/data/Tree/context/async-children/index.ts +8 -0
- package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
- package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
- package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
- package/src/tools/data/Tree/context/dnd/index.ts +8 -0
- package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
- package/src/tools/data/Tree/context/expansion/index.ts +4 -0
- package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
- package/src/tools/data/Tree/context/hooks.ts +68 -1
- package/src/tools/data/Tree/context/index.ts +3 -0
- package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
- package/src/tools/data/Tree/context/menu/index.ts +10 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
- package/src/tools/data/Tree/context/persist/index.ts +4 -0
- package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
- package/src/tools/data/Tree/context/rename/index.ts +4 -0
- package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
- package/src/tools/data/Tree/context/selection/index.ts +4 -0
- package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
- package/src/tools/data/Tree/context/state/index.ts +6 -0
- package/src/tools/data/Tree/context/state/initial.ts +41 -0
- package/src/tools/data/Tree/context/state/reducer.ts +76 -0
- package/src/tools/data/Tree/context/state/types.ts +46 -0
- package/src/tools/data/Tree/data/clipboard.ts +33 -0
- package/src/tools/data/Tree/data/dnd.ts +123 -0
- package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
- package/src/tools/data/Tree/data/index.ts +19 -0
- package/src/tools/data/Tree/data/renameUtils.ts +51 -0
- package/src/tools/data/Tree/data/selection.ts +157 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
- package/src/tools/data/Tree/hooks/index.ts +23 -4
- package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
- package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
- package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
- package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
- package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
- package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
- package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
- package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
- package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
- package/src/tools/data/Tree/index.tsx +25 -2
- package/src/tools/data/Tree/types/activation.ts +30 -0
- package/src/tools/data/Tree/types/adapter.ts +70 -0
- package/src/tools/data/Tree/types/index.ts +27 -0
- package/src/tools/data/Tree/types/labels.ts +97 -0
- package/src/tools/data/Tree/types/loader.ts +9 -0
- package/src/tools/data/Tree/types/node.ts +38 -0
- package/src/tools/data/Tree/types/root-props.ts +142 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
- package/src/tools/forms/MarkdownEditor/index.ts +1 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
- package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
- package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
- package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
- package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
- package/src/tools/forms/MarkdownEditor/styles.css +18 -0
- package/dist/types-j2vhn4Kv.d.cts +0 -241
- package/dist/types-j2vhn4Kv.d.ts +0 -241
- package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
- package/src/tools/data/Tree/types.ts +0 -217
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { TreeAppearance } from '../data/appearance';
|
|
6
|
+
import type { TreeActivateOptions, TreeActivationMode } from './activation';
|
|
7
|
+
import type {
|
|
8
|
+
TreeAdapter,
|
|
9
|
+
TreeBuiltinAction,
|
|
10
|
+
TreeMovePosition,
|
|
11
|
+
} from './adapter';
|
|
12
|
+
import type { TreeLabels } from './labels';
|
|
13
|
+
import type { TreeLoadChildren } from './loader';
|
|
14
|
+
import type { TreeItemId, TreeNode } from './node';
|
|
15
|
+
import type { TreeSelectionMode } from './selection';
|
|
16
|
+
import type {
|
|
17
|
+
TreeContextMenuActionsResolver,
|
|
18
|
+
TreeContextMenuSlot,
|
|
19
|
+
TreeRowSlot,
|
|
20
|
+
} from './slots';
|
|
21
|
+
|
|
22
|
+
export interface TreeRootProps<T> {
|
|
23
|
+
/** Root nodes. Top-level items are rendered directly (no synthetic root). */
|
|
24
|
+
data: TreeNode<T>[];
|
|
25
|
+
/** Returns the human-readable name for a node (used by search/type-ahead). */
|
|
26
|
+
getItemName: (node: TreeNode<T>) => string;
|
|
27
|
+
|
|
28
|
+
/** Async loader for folders without inline `children`. */
|
|
29
|
+
loadChildren?: TreeLoadChildren<T>;
|
|
30
|
+
|
|
31
|
+
/** Selection behaviour. Default: `'single'`. */
|
|
32
|
+
selectionMode?: TreeSelectionMode;
|
|
33
|
+
/** Pointer activation behaviour. Default: `'single-click'`. */
|
|
34
|
+
activationMode?: TreeActivationMode;
|
|
35
|
+
/** Initially expanded ids. */
|
|
36
|
+
initialExpandedIds?: TreeItemId[];
|
|
37
|
+
/** Initially selected ids. */
|
|
38
|
+
initialSelectedIds?: TreeItemId[];
|
|
39
|
+
/** Pixels of indent per nesting level. Default: 16. (Shortcut for `appearance.indent`.) */
|
|
40
|
+
indent?: number;
|
|
41
|
+
/** Cosmetic configuration: density, sizes, accent intensity, radius. */
|
|
42
|
+
appearance?: TreeAppearance;
|
|
43
|
+
|
|
44
|
+
/** Triggered when selection changes. */
|
|
45
|
+
onSelectionChange?: (selectedIds: TreeItemId[]) => void;
|
|
46
|
+
/** Triggered when expanded set changes. */
|
|
47
|
+
onExpansionChange?: (expandedIds: TreeItemId[]) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Triggered when a leaf is activated (Enter / dblclick / click depending
|
|
50
|
+
* on `activationMode`). Folders never call this — they toggle instead.
|
|
51
|
+
*/
|
|
52
|
+
onActivate?: (node: TreeNode<T>, opts: TreeActivateOptions) => void;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Optional predicate. Nodes returning `false` (and their descendants) are
|
|
56
|
+
* not rendered and not searchable. Use this to hide dot-files, system
|
|
57
|
+
* entries, or anything else the consumer wants to filter out.
|
|
58
|
+
*/
|
|
59
|
+
filterNode?: (node: TreeNode<T>) => boolean;
|
|
60
|
+
|
|
61
|
+
/** Show built-in search input. Default: false. */
|
|
62
|
+
enableSearch?: boolean;
|
|
63
|
+
/** Type printable letters to jump to a matching name. Default: true. */
|
|
64
|
+
enableTypeAhead?: boolean;
|
|
65
|
+
/** Render vertical indent guides under expanded folders. Default: false. */
|
|
66
|
+
showIndentGuides?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Allow inline rename. When true, F2 starts an inline `<input>`; the
|
|
69
|
+
* value is committed through `adapter.rename`. Requires `adapter.rename`.
|
|
70
|
+
* Default: false.
|
|
71
|
+
*/
|
|
72
|
+
enableInlineRename?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Enable Finder/Explorer keyboard shortcuts (delete, rename, duplicate,
|
|
75
|
+
* new file/folder, cut/copy/paste). Bindings only fire when the tree
|
|
76
|
+
* container has focus. Individual shortcuts are still gated by the
|
|
77
|
+
* adapter — `⌘⌫` does nothing if `adapter.remove` is undefined.
|
|
78
|
+
* Default: false.
|
|
79
|
+
*/
|
|
80
|
+
enableFinderHotkeys?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Enable drag-and-drop reorder + move-into-folder. Requires
|
|
83
|
+
* `adapter.move`. Powered by `@dnd-kit/core` — pointer + keyboard
|
|
84
|
+
* sensors, accessible.
|
|
85
|
+
* Default: false.
|
|
86
|
+
*/
|
|
87
|
+
enableDnD?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Custom drop validation. Returns `true` to allow, `false` to forbid.
|
|
90
|
+
* The default validator rejects self-drops and cycles (dropping a
|
|
91
|
+
* folder into its own descendant). Use this to add domain rules
|
|
92
|
+
* (read-only branches, type matching, …).
|
|
93
|
+
*/
|
|
94
|
+
canDrop?: (ctx: {
|
|
95
|
+
source: TreeNode<T>[];
|
|
96
|
+
target: TreeNode<T> | null;
|
|
97
|
+
position: TreeMovePosition;
|
|
98
|
+
}) => boolean;
|
|
99
|
+
|
|
100
|
+
/** Custom row renderer. Falls back to the default <TreeRow />. */
|
|
101
|
+
renderRow?: TreeRowSlot<T>;
|
|
102
|
+
/** Replace default folder/file icon. */
|
|
103
|
+
renderIcon?: TreeRowSlot<T>;
|
|
104
|
+
/** Replace default label rendering. */
|
|
105
|
+
renderLabel?: TreeRowSlot<T>;
|
|
106
|
+
/** Right-side actions slot (per row). */
|
|
107
|
+
renderActions?: TreeRowSlot<T>;
|
|
108
|
+
/** Wrap each row in a context menu (right-click). Receives the row meta + trigger element. */
|
|
109
|
+
renderContextMenu?: TreeContextMenuSlot<T>;
|
|
110
|
+
/**
|
|
111
|
+
* Declarative right-click menu — short-form. Pass `(row) => [items]` and the
|
|
112
|
+
* Tree builds a `<ContextMenu>` for you with sensible defaults. Ignored if
|
|
113
|
+
* `renderContextMenu` is also set. Return `null`/`undefined`/`[]` to skip
|
|
114
|
+
* the menu for that row.
|
|
115
|
+
*/
|
|
116
|
+
contextMenuActions?: TreeContextMenuActionsResolver<T>;
|
|
117
|
+
|
|
118
|
+
/** Override built-in copy in your locale. */
|
|
119
|
+
labels?: Partial<TreeLabels>;
|
|
120
|
+
/** Persist expanded + (optional) selected ids in localStorage under this key. */
|
|
121
|
+
persistKey?: string;
|
|
122
|
+
/** Persist selection alongside expansion. Default: false. */
|
|
123
|
+
persistSelection?: boolean;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* CRUD adapter. When set, Tree builds default context-menu items and
|
|
127
|
+
* Finder hotkeys for every method the adapter defines. Methods that
|
|
128
|
+
* are not provided produce no UI — no greyed-out items.
|
|
129
|
+
*/
|
|
130
|
+
adapter?: TreeAdapter<T>;
|
|
131
|
+
/**
|
|
132
|
+
* Which built-in actions to expose in the auto-built context menu. If
|
|
133
|
+
* omitted, every action whose adapter method exists is shown.
|
|
134
|
+
*
|
|
135
|
+
* Pass `[]` to suppress the auto-built menu entirely while keeping the
|
|
136
|
+
* adapter for hotkeys / DnD.
|
|
137
|
+
*/
|
|
138
|
+
defaultMenuItems?: TreeBuiltinAction[];
|
|
139
|
+
|
|
140
|
+
className?: string;
|
|
141
|
+
style?: CSSProperties;
|
|
142
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { TreeNode } from './node';
|
|
6
|
+
|
|
7
|
+
export interface TreeRowRenderProps<T> {
|
|
8
|
+
node: TreeNode<T>;
|
|
9
|
+
level: number;
|
|
10
|
+
isSelected: boolean;
|
|
11
|
+
isExpanded: boolean;
|
|
12
|
+
isFocused: boolean;
|
|
13
|
+
isFolder: boolean;
|
|
14
|
+
isLoading: boolean;
|
|
15
|
+
isMatchingSearch: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TreeRowSlot<T> = (props: TreeRowRenderProps<T>) => ReactNode;
|
|
19
|
+
|
|
20
|
+
export type TreeContextMenuSlot<T> = (
|
|
21
|
+
props: TreeRowRenderProps<T>,
|
|
22
|
+
trigger: ReactNode,
|
|
23
|
+
) => ReactNode;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Declarative context-menu item. Pass `'separator'` (string) in place of an
|
|
27
|
+
* object to insert a `<ContextMenuSeparator />` between groups.
|
|
28
|
+
*
|
|
29
|
+
* For more advanced needs (submenus, checkbox items, custom JSX), drop down
|
|
30
|
+
* to `renderContextMenu` instead.
|
|
31
|
+
*/
|
|
32
|
+
export interface TreeContextMenuAction<T> {
|
|
33
|
+
/** Stable React key. */
|
|
34
|
+
id: string;
|
|
35
|
+
label: ReactNode;
|
|
36
|
+
/** Lucide-style icon component. Rendered as `<icon className="size-4" />`. */
|
|
37
|
+
icon?: ComponentType<{ className?: string }>;
|
|
38
|
+
/** Right-aligned keyboard hint (e.g. `'⌘C'`, `'↵'`). Cosmetic. */
|
|
39
|
+
shortcut?: ReactNode;
|
|
40
|
+
/** Disable the item — still rendered, not selectable. */
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** Style as destructive (red). */
|
|
43
|
+
destructive?: boolean;
|
|
44
|
+
/** Click / Enter handler. Receives the row meta. */
|
|
45
|
+
onSelect: (props: TreeRowRenderProps<T>) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TreeContextMenuItem<T> = TreeContextMenuAction<T> | 'separator';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Context passed to a `contextMenuActions` resolver. `row` is the row the
|
|
52
|
+
* user right-clicked. `selectedNodes` is the multi-selection at the
|
|
53
|
+
* moment the menu opens — when the right-clicked row was not in the
|
|
54
|
+
* selection, Tree first switches selection to that single row, so
|
|
55
|
+
* `selectedNodes` always contains the right-clicked node.
|
|
56
|
+
*/
|
|
57
|
+
export interface TreeContextMenuActionsContext<T> extends TreeRowRenderProps<T> {
|
|
58
|
+
/** Multi-selection at the moment the menu opens. Always non-empty. */
|
|
59
|
+
selectedNodes: TreeNode<T>[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type TreeContextMenuActionsResolver<T> = (
|
|
63
|
+
ctx: TreeContextMenuActionsContext<T>,
|
|
64
|
+
) => TreeContextMenuItem<T>[] | null | undefined;
|
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
} from 'lucide-react';
|
|
14
14
|
import { createMentionSuggestion } from './createMentionSuggestion';
|
|
15
15
|
import { mentionPresets } from './mentionPresets';
|
|
16
|
+
import { SlashCommandNode } from './slash/SlashCommandNode';
|
|
17
|
+
import { syncLeadingSlashNode } from './slash/syncSlashNode';
|
|
18
|
+
import type { SlashCommandInfo } from './slash/types';
|
|
16
19
|
import { SubmitOnEnter } from './submitOnEnter';
|
|
17
20
|
import type { MentionAttrs, MentionConfig } from './types';
|
|
18
21
|
import './styles.css';
|
|
@@ -84,6 +87,23 @@ export interface MarkdownEditorProps {
|
|
|
84
87
|
* no-op for the live editor.
|
|
85
88
|
*/
|
|
86
89
|
mentions?: MentionConfig;
|
|
90
|
+
/**
|
|
91
|
+
* Slash-command verb list. When set, the editor registers a
|
|
92
|
+
* `SlashCommandNode` extension and replaces a leading `/verb` (whose
|
|
93
|
+
* verb is in this list) in the document with an atomic chip — the
|
|
94
|
+
* TipTap analogue of the plain `<SlashHighlightTextarea>` mirror.
|
|
95
|
+
*
|
|
96
|
+
* Like `mentions`, this is captured by `useEditor` exactly once on
|
|
97
|
+
* first render. To register slash commands at all, pass `[]` from
|
|
98
|
+
* the very first render and mutate / swap as needed. The conversion
|
|
99
|
+
* effect re-reads the list on every value sync (it lives in a ref),
|
|
100
|
+
* so verbs added later are recognised next time the buffer is set.
|
|
101
|
+
*
|
|
102
|
+
* Lives in `@djangocfg/ui-tools/markdown-editor` (not chat) so the
|
|
103
|
+
* editor stays a leaf dependency. Structurally compatible with the
|
|
104
|
+
* chat package's `SlashCommand[]` — pass either directly.
|
|
105
|
+
*/
|
|
106
|
+
slashCommands?: readonly SlashCommandInfo[];
|
|
87
107
|
/** Called when mentioned IDs change */
|
|
88
108
|
onMentionIdsChange?: (ids: string[]) => void;
|
|
89
109
|
/**
|
|
@@ -136,6 +156,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|
|
136
156
|
showToolbar = true,
|
|
137
157
|
unstyled = false,
|
|
138
158
|
mentions,
|
|
159
|
+
slashCommands,
|
|
139
160
|
onMentionIdsChange,
|
|
140
161
|
onSubmit,
|
|
141
162
|
},
|
|
@@ -150,6 +171,35 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|
|
150
171
|
onSubmitRef.current = onSubmit;
|
|
151
172
|
const isExternalUpdate = useRef(false);
|
|
152
173
|
|
|
174
|
+
// Slash list lives in a ref so verbs added at runtime (e.g. context-
|
|
175
|
+
// aware commands registered after a first reply) are still recognised
|
|
176
|
+
// by the post-`setContent` conversion pass. The editor extension itself
|
|
177
|
+
// is registered once on mount — that is intentional and matches how
|
|
178
|
+
// `mentions` works (Tiptap's `useEditor` captures extensions once).
|
|
179
|
+
const slashCommandsRef = useRef<readonly SlashCommandInfo[]>(
|
|
180
|
+
slashCommands ?? [],
|
|
181
|
+
);
|
|
182
|
+
slashCommandsRef.current = slashCommands ?? [];
|
|
183
|
+
const slashEnabledOnMountRef = useRef<boolean>(slashCommands !== undefined);
|
|
184
|
+
const slashWarnedRef = useRef(false);
|
|
185
|
+
if (
|
|
186
|
+
process.env.NODE_ENV !== 'production' &&
|
|
187
|
+
!slashEnabledOnMountRef.current &&
|
|
188
|
+
slashCommands !== undefined &&
|
|
189
|
+
!slashWarnedRef.current
|
|
190
|
+
) {
|
|
191
|
+
slashWarnedRef.current = true;
|
|
192
|
+
// eslint-disable-next-line no-console
|
|
193
|
+
console.warn(
|
|
194
|
+
'[MarkdownEditor] `slashCommands` flipped from undefined to a list ' +
|
|
195
|
+
'after mount. Tiptap only installs the SlashCommandNode extension ' +
|
|
196
|
+
'on first render — the in-editor chip will NOT appear for this ' +
|
|
197
|
+
'editor instance. Pass `[]` from the very first render and mutate ' +
|
|
198
|
+
'the array in place (or swap references) so the conversion effect ' +
|
|
199
|
+
'sees the new verbs.',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
153
203
|
// ── Dev-mode trap detector ──
|
|
154
204
|
// Tiptap initialises the editor once with the extensions array from
|
|
155
205
|
// first render. If `mentions` is undefined on mount and becomes
|
|
@@ -194,6 +244,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|
|
194
244
|
}),
|
|
195
245
|
];
|
|
196
246
|
|
|
247
|
+
// SlashCommandNode — TipTap atom that paints the `/verb` chip.
|
|
248
|
+
// Registered whenever `slashCommands` was defined on first render
|
|
249
|
+
// (the empty array still counts as "I want this feature"). The
|
|
250
|
+
// node has no suggestion plugin of its own — the chat composer's
|
|
251
|
+
// floating menu drives selection and the editor only needs to
|
|
252
|
+
// know how to render + serialize the atom.
|
|
253
|
+
if (slashEnabledOnMountRef.current) {
|
|
254
|
+
exts.push(SlashCommandNode);
|
|
255
|
+
}
|
|
256
|
+
|
|
197
257
|
if (mentions) {
|
|
198
258
|
// ── Why .extend() with renderMarkdown ──
|
|
199
259
|
//
|
|
@@ -260,6 +320,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|
|
260
320
|
contentType: 'markdown',
|
|
261
321
|
onUpdate: ({ editor }) => {
|
|
262
322
|
if (isExternalUpdate.current) return;
|
|
323
|
+
// Promote a freshly-typed `/verb ` text run into a chip so users
|
|
324
|
+
// who type the command without going through the menu still see
|
|
325
|
+
// the highlight — matches the plain mirror's behaviour where
|
|
326
|
+
// `extractSlashToken` works for any leading slash. The helper is
|
|
327
|
+
// idempotent and a no-op when no leading text-slash exists.
|
|
328
|
+
if (slashEnabledOnMountRef.current) {
|
|
329
|
+
const replaced = syncLeadingSlashNode(editor, slashCommandsRef.current);
|
|
330
|
+
if (replaced) {
|
|
331
|
+
// The chain() above triggers an extra onUpdate. The string
|
|
332
|
+
// serialisation is unchanged (atom flattens to its token via
|
|
333
|
+
// `renderText`), so we still want the outer onChange to fire
|
|
334
|
+
// with the canonical value. Falling through is fine.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
263
337
|
onChange(getMarkdown(editor));
|
|
264
338
|
|
|
265
339
|
if (onMentionIdsChange) {
|
|
@@ -284,6 +358,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|
|
284
358
|
if (current !== value) {
|
|
285
359
|
isExternalUpdate.current = true;
|
|
286
360
|
editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
|
|
361
|
+
// After the new content is written, convert a leading `/verb` text
|
|
362
|
+
// run into a `SlashCommandNode` atom so the chip appears. The
|
|
363
|
+
// helper is a no-op when (a) slash commands are not enabled,
|
|
364
|
+
// (b) the buffer doesn't start with a known verb, or (c) the
|
|
365
|
+
// document already begins with the atom. `emitUpdate: false` on
|
|
366
|
+
// setContent + `isExternalUpdate.current` still being `true`
|
|
367
|
+
// suppresses the onUpdate -> onChange feedback loop the chain()
|
|
368
|
+
// call would otherwise trigger.
|
|
369
|
+
if (slashEnabledOnMountRef.current) {
|
|
370
|
+
syncLeadingSlashNode(editor, slashCommandsRef.current);
|
|
371
|
+
}
|
|
287
372
|
isExternalUpdate.current = false;
|
|
288
373
|
}
|
|
289
374
|
}, [value, editor]);
|
|
@@ -65,3 +65,9 @@ export type {
|
|
|
65
65
|
MentionAttrs,
|
|
66
66
|
MentionMarkdownRenderer,
|
|
67
67
|
} from './types';
|
|
68
|
+
|
|
69
|
+
// Slash command surface — opt-in by passing `slashCommands` to
|
|
70
|
+
// `MarkdownEditor` (or `<ComposerRichTextarea>`, which forwards). The
|
|
71
|
+
// node itself is mounted by the editor on first render. `SlashCommandInfo`
|
|
72
|
+
// is structurally compatible with the chat package's `SlashCommand`.
|
|
73
|
+
export type { SlashCommandInfo } from './slash/types';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SlashCommandNode` — TipTap inline atom that renders a chosen slash
|
|
3
|
+
* verb as a styled chip inside the editor, analogous to how
|
|
4
|
+
* `@tiptap/extension-mention` renders an `@user` pill.
|
|
5
|
+
*
|
|
6
|
+
* Why a node (and not a mark / decoration):
|
|
7
|
+
*
|
|
8
|
+
* - The chip must read as one indivisible unit — pressing Backspace
|
|
9
|
+
* removes the whole `/verb`, not the trailing letter. That's
|
|
10
|
+
* exactly what `atom: true` + `inline: true` give us.
|
|
11
|
+
* - The chip's text content (`/verb`) participates in `editor.getText()`
|
|
12
|
+
* and the markdown serializer's text output so the host's
|
|
13
|
+
* `composer.value` round-trips to the same plain string the plain
|
|
14
|
+
* `<SlashHighlightTextarea>` produces. The slash hook (which lives on
|
|
15
|
+
* the string buffer) stays oblivious to the node form.
|
|
16
|
+
*
|
|
17
|
+
* Render parity with the plain mirror:
|
|
18
|
+
*
|
|
19
|
+
* The chip class `markdown-slash-command` mirrors the plain textarea's
|
|
20
|
+
* `bg-primary/15 text-primary` look (see `SlashHighlightTextarea.tsx`).
|
|
21
|
+
* Styles live in `../styles.css`. The TipTap chip can carry a touch of
|
|
22
|
+
* horizontal padding without breaking the caret — the node is atomic
|
|
23
|
+
* so the caret never lands inside it — which the plain mirror cannot.
|
|
24
|
+
*/
|
|
25
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
26
|
+
|
|
27
|
+
declare module '@tiptap/core' {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
29
|
+
interface Commands<ReturnType> {
|
|
30
|
+
slashCommand: {
|
|
31
|
+
/**
|
|
32
|
+
* Insert a slash-command atom at the given absolute document
|
|
33
|
+
* position. The host normally calls this once per `pick()`
|
|
34
|
+
* after the slash hook has produced the new string buffer.
|
|
35
|
+
*/
|
|
36
|
+
insertSlashCommandAt: (
|
|
37
|
+
pos: number,
|
|
38
|
+
from: number,
|
|
39
|
+
to: number,
|
|
40
|
+
attrs: { id: string; token: string },
|
|
41
|
+
) => ReturnType;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SlashCommandNodeOptions {
|
|
47
|
+
/** Extra HTML attrs spread onto the rendered `<span>`. */
|
|
48
|
+
HTMLAttributes: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Inline atom node that paints a `/verb` chip inside the TipTap editor.
|
|
53
|
+
*
|
|
54
|
+
* - `inline: true` — lives next to text in a paragraph.
|
|
55
|
+
* - `atom: true` — caret cannot land inside it; Backspace removes it
|
|
56
|
+
* wholesale, matching the plain mirror's "the verb is one token"
|
|
57
|
+
* feel.
|
|
58
|
+
* - `selectable: false` — clicking the chip does not stick a node
|
|
59
|
+
* selection on it (mention does the same).
|
|
60
|
+
*
|
|
61
|
+
* `renderText` returns the bare `/verb` so `editor.getText()` (and
|
|
62
|
+
* therefore the host `composer.value`) reads the same string the plain
|
|
63
|
+
* mirror produces. This is what keeps the slash hook — which only
|
|
64
|
+
* understands strings — driving the menu correctly when the editor is
|
|
65
|
+
* TipTap-backed.
|
|
66
|
+
*/
|
|
67
|
+
export const SlashCommandNode = Node.create<SlashCommandNodeOptions>({
|
|
68
|
+
name: 'slashCommand',
|
|
69
|
+
group: 'inline',
|
|
70
|
+
inline: true,
|
|
71
|
+
atom: true,
|
|
72
|
+
selectable: false,
|
|
73
|
+
// Higher than StarterKit text so paste rules etc. don't try to
|
|
74
|
+
// re-parse our chip text as plain.
|
|
75
|
+
priority: 101,
|
|
76
|
+
|
|
77
|
+
addOptions() {
|
|
78
|
+
return {
|
|
79
|
+
HTMLAttributes: {},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
addAttributes() {
|
|
84
|
+
return {
|
|
85
|
+
id: {
|
|
86
|
+
default: null,
|
|
87
|
+
parseHTML: (element) => element.getAttribute('data-id'),
|
|
88
|
+
renderHTML: (attrs) =>
|
|
89
|
+
attrs.id ? { 'data-id': attrs.id as string } : {},
|
|
90
|
+
},
|
|
91
|
+
token: {
|
|
92
|
+
default: null,
|
|
93
|
+
parseHTML: (element) => element.getAttribute('data-token'),
|
|
94
|
+
renderHTML: (attrs) =>
|
|
95
|
+
attrs.token ? { 'data-token': attrs.token as string } : {},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
parseHTML() {
|
|
101
|
+
return [{ tag: `span[data-type="${this.name}"]` }];
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
105
|
+
const token =
|
|
106
|
+
(node.attrs.token as string | null) ??
|
|
107
|
+
(node.attrs.id ? `/${node.attrs.id as string}` : '/');
|
|
108
|
+
return [
|
|
109
|
+
'span',
|
|
110
|
+
mergeAttributes(
|
|
111
|
+
{ 'data-type': this.name, class: 'markdown-slash-command' },
|
|
112
|
+
this.options.HTMLAttributes,
|
|
113
|
+
HTMLAttributes,
|
|
114
|
+
),
|
|
115
|
+
token,
|
|
116
|
+
];
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
// `editor.getText()` and the markdown text-serializer fallback read
|
|
120
|
+
// this — the chip flattens to its raw `/verb` glyph so consumers
|
|
121
|
+
// (slash hook, transport, backends) see plain text.
|
|
122
|
+
renderText({ node }) {
|
|
123
|
+
const token =
|
|
124
|
+
(node.attrs.token as string | null) ??
|
|
125
|
+
(node.attrs.id ? `/${node.attrs.id as string}` : '/');
|
|
126
|
+
return token;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// `@tiptap/markdown` reads `renderMarkdown` off the extension at
|
|
130
|
+
// registration time. We emit the bare token, NOT the default
|
|
131
|
+
// shortcode `[slashCommand id="..."]`, so a round-trip through
|
|
132
|
+
// `getMarkdown()` yields the same `/verb argument` string the plain
|
|
133
|
+
// mirror produces. Slash commands never need re-parsed back from
|
|
134
|
+
// markdown — fresh value sync handles that via
|
|
135
|
+
// `syncLeadingSlashNode` after each `setContent`.
|
|
136
|
+
renderMarkdown(node) {
|
|
137
|
+
const attrs = node.attrs as { token?: string | null; id?: string | null };
|
|
138
|
+
const token = attrs.token ?? (attrs.id ? `/${attrs.id}` : '');
|
|
139
|
+
return token;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
addCommands() {
|
|
143
|
+
return {
|
|
144
|
+
insertSlashCommandAt:
|
|
145
|
+
(pos, from, to, attrs) =>
|
|
146
|
+
({ chain }) => {
|
|
147
|
+
// Replace the [from, to) range (typically the leading `/verb`
|
|
148
|
+
// text the user just had the menu narrow down) with the atom
|
|
149
|
+
// node. `pos` is unused here but kept in the signature for
|
|
150
|
+
// future use (e.g. cursor-driven insertion not anchored at
|
|
151
|
+
// the start of the doc).
|
|
152
|
+
void pos;
|
|
153
|
+
return chain()
|
|
154
|
+
.insertContentAt(
|
|
155
|
+
{ from, to },
|
|
156
|
+
{ type: 'slashCommand', attrs },
|
|
157
|
+
)
|
|
158
|
+
.run();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `syncSlashNode` — bridge between the slash hook's string buffer and
|
|
3
|
+
* the TipTap document's node form.
|
|
4
|
+
*
|
|
5
|
+
* The slash hook lives entirely on the editor value string — it has no
|
|
6
|
+
* concept of nodes. When the host wires a TipTap-backed
|
|
7
|
+
* `<ComposerRichTextarea>` with `slashCommands`, the document needs to
|
|
8
|
+
* pick up two things on its own:
|
|
9
|
+
*
|
|
10
|
+
* 1. After `setContent(newValue)` runs (e.g. the user picked `/verb`
|
|
11
|
+
* from the menu so `composer.value` became `"/verb "`), the
|
|
12
|
+
* leading `/verb` text must turn into a `SlashCommandNode` atom
|
|
13
|
+
* so the chip shows up.
|
|
14
|
+
* 2. If the user backspaces past the chip or types non-slash text,
|
|
15
|
+
* the buffer is no longer a slash command — there's nothing to
|
|
16
|
+
* do because the atom would have been removed wholesale by the
|
|
17
|
+
* backspace.
|
|
18
|
+
*
|
|
19
|
+
* This module owns the post-`setContent` "absorb the leading slash"
|
|
20
|
+
* pass. It is the only piece of TipTap-aware code the slash node needs
|
|
21
|
+
* beyond the node definition itself.
|
|
22
|
+
*/
|
|
23
|
+
import type { Editor } from '@tiptap/react';
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
SlashCommandInfo,
|
|
27
|
+
} from './types';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Find the leading `/verb` in the document — if and only if it appears
|
|
31
|
+
* as the very first inline text of the first paragraph. Returns
|
|
32
|
+
* `{ verb, from, to }` (`from` / `to` are absolute document positions
|
|
33
|
+
* usable with `insertContentAt`) or `null` when no leading slash is
|
|
34
|
+
* present.
|
|
35
|
+
*/
|
|
36
|
+
function findLeadingSlashText(
|
|
37
|
+
editor: Editor,
|
|
38
|
+
commands: readonly SlashCommandInfo[],
|
|
39
|
+
): { verb: SlashCommandInfo; from: number; to: number } | null {
|
|
40
|
+
const doc = editor.state.doc;
|
|
41
|
+
if (doc.childCount === 0) return null;
|
|
42
|
+
// Walk the document's first inline block. The buffer must START with
|
|
43
|
+
// the slash — verbs in the middle of the message are not slash
|
|
44
|
+
// commands (matches `parseSlashState` semantics).
|
|
45
|
+
const firstBlock = doc.firstChild;
|
|
46
|
+
if (!firstBlock || firstBlock.childCount === 0) return null;
|
|
47
|
+
const firstInline = firstBlock.firstChild;
|
|
48
|
+
if (!firstInline) return null;
|
|
49
|
+
// If the first inline is already a slash atom we're done.
|
|
50
|
+
if (firstInline.type.name === 'slashCommand') return null;
|
|
51
|
+
if (!firstInline.isText) return null;
|
|
52
|
+
const text = firstInline.text ?? '';
|
|
53
|
+
// Mirrors SLASH_RE from ../../../chat/composer/slash/state.ts. Kept
|
|
54
|
+
// inline to avoid pulling the chat package into the editor module.
|
|
55
|
+
const m = /^\/([a-zA-Z][\w-]*)(?:[ \n]|$)/.exec(text);
|
|
56
|
+
if (!m) return null;
|
|
57
|
+
const verb = m[1]?.toLowerCase() ?? '';
|
|
58
|
+
const cmd = commands.find((c) => c.id === verb);
|
|
59
|
+
if (!cmd) return null;
|
|
60
|
+
const slashLen = 1 + (m[1]?.length ?? 0); // `/verb` length, no trailing
|
|
61
|
+
// First text child of the first block starts at position 1 in the
|
|
62
|
+
// document (the block boundary takes position 0).
|
|
63
|
+
const from = 1;
|
|
64
|
+
const to = from + slashLen;
|
|
65
|
+
return { verb: cmd, from, to };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* If the document starts with a `/verb` text node whose verb is in
|
|
70
|
+
* `commands`, replace that text with a `SlashCommandNode` atom. No-op
|
|
71
|
+
* otherwise. Idempotent — calling it on a document that already starts
|
|
72
|
+
* with the atom does nothing.
|
|
73
|
+
*
|
|
74
|
+
* Returns `true` when a replacement happened (so callers can suppress
|
|
75
|
+
* a feedback `onUpdate` if needed).
|
|
76
|
+
*/
|
|
77
|
+
export function syncLeadingSlashNode(
|
|
78
|
+
editor: Editor,
|
|
79
|
+
commands: readonly SlashCommandInfo[],
|
|
80
|
+
): boolean {
|
|
81
|
+
if (!editor || editor.isDestroyed) return false;
|
|
82
|
+
if (commands.length === 0) return false;
|
|
83
|
+
const found = findLeadingSlashText(editor, commands);
|
|
84
|
+
if (!found) return false;
|
|
85
|
+
const { verb, from, to } = found;
|
|
86
|
+
editor
|
|
87
|
+
.chain()
|
|
88
|
+
.insertContentAt(
|
|
89
|
+
{ from, to },
|
|
90
|
+
{
|
|
91
|
+
type: 'slashCommand',
|
|
92
|
+
attrs: { id: verb.id, token: verb.token },
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
.run();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor-side slash command info — a deliberately tiny subset of the
|
|
3
|
+
* chat package's `SlashCommand` so the editor module does not depend
|
|
4
|
+
* on `@djangocfg/ui-tools/chat`. Hosts pass either the chat package's
|
|
5
|
+
* `SlashCommand[]` directly (structural compatibility) or this minimal
|
|
6
|
+
* shape.
|
|
7
|
+
*/
|
|
8
|
+
export interface SlashCommandInfo {
|
|
9
|
+
/** Verb without the leading slash (e.g. `"clear"`). */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Display token with the slash (e.g. `"/clear"`). */
|
|
12
|
+
token: string;
|
|
13
|
+
}
|
|
@@ -116,6 +116,24 @@
|
|
|
116
116
|
box-shadow: none;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/* Slash-command inline chip — TipTap atom analogue of the plain
|
|
120
|
+
`<SlashHighlightTextarea>` mirror. Mirrors `bg-primary/15 text-primary`
|
|
121
|
+
so the two paths look identical. Atom nodes don't admit a caret, so
|
|
122
|
+
light horizontal padding is safe here (unlike the plain mirror,
|
|
123
|
+
which has to be padding-less to keep caret alignment). */
|
|
124
|
+
.markdown-slash-command {
|
|
125
|
+
background: color-mix(in oklab, var(--color-primary, var(--primary)) 15%, transparent);
|
|
126
|
+
color: var(--color-primary, var(--primary));
|
|
127
|
+
padding: 0 2px;
|
|
128
|
+
border-radius: 3px;
|
|
129
|
+
font-weight: 500;
|
|
130
|
+
/* Atom nodes can't be entered; reflect that visually so the user does
|
|
131
|
+
not try to click into the chip to edit it. */
|
|
132
|
+
cursor: default;
|
|
133
|
+
user-select: none;
|
|
134
|
+
display: inline;
|
|
135
|
+
}
|
|
136
|
+
|
|
119
137
|
/* Mention inline chip */
|
|
120
138
|
.markdown-mention {
|
|
121
139
|
background: var(--color-primary, var(--primary));
|