@djangocfg/ui-tools 2.1.376 → 2.1.377
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/ChatRoot-AWNBBBH7.cjs +14 -0
- package/dist/{ChatRoot-F5XXERXU.cjs.map → ChatRoot-AWNBBBH7.cjs.map} +1 -1
- package/dist/ChatRoot-VJKOAVPQ.mjs +5 -0
- package/dist/{ChatRoot-T7D7QRCH.mjs.map → ChatRoot-VJKOAVPQ.mjs.map} +1 -1
- package/dist/{chunk-JXBEKSNT.mjs → chunk-BDWVCSM5.mjs} +29 -13
- package/dist/chunk-BDWVCSM5.mjs.map +1 -0
- package/dist/{chunk-UVIFD3TH.cjs → chunk-TUZZROQU.cjs} +30 -12
- package/dist/chunk-TUZZROQU.cjs.map +1 -0
- package/dist/index.cjs +102 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +83 -2
- package/dist/index.d.ts +83 -2
- package/dist/index.mjs +48 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/Chat/README.md +51 -0
- package/src/tools/Chat/hooks/useChatComposer.ts +41 -9
- package/src/tools/Chat/index.ts +5 -0
- package/src/tools/Chat/utils/sanitizeDraft.ts +72 -0
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +40 -0
- package/src/tools/MarkdownEditor/README.md +34 -0
- package/src/tools/MarkdownEditor/submitOnEnter.ts +67 -0
- package/dist/ChatRoot-F5XXERXU.cjs +0 -14
- package/dist/ChatRoot-T7D7QRCH.mjs +0 -5
- package/dist/chunk-JXBEKSNT.mjs.map +0 -1
- package/dist/chunk-UVIFD3TH.cjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.377",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -156,8 +156,8 @@
|
|
|
156
156
|
"check": "tsc --noEmit"
|
|
157
157
|
},
|
|
158
158
|
"peerDependencies": {
|
|
159
|
-
"@djangocfg/i18n": "^2.1.
|
|
160
|
-
"@djangocfg/ui-core": "^2.1.
|
|
159
|
+
"@djangocfg/i18n": "^2.1.377",
|
|
160
|
+
"@djangocfg/ui-core": "^2.1.377",
|
|
161
161
|
"consola": "^3.4.2",
|
|
162
162
|
"lodash-es": "^4.18.1",
|
|
163
163
|
"lucide-react": "^0.545.0",
|
|
@@ -211,10 +211,10 @@
|
|
|
211
211
|
"material-file-icons": "^2.4.0"
|
|
212
212
|
},
|
|
213
213
|
"devDependencies": {
|
|
214
|
-
"@djangocfg/i18n": "^2.1.
|
|
214
|
+
"@djangocfg/i18n": "^2.1.377",
|
|
215
215
|
"@djangocfg/playground": "workspace:*",
|
|
216
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
217
|
-
"@djangocfg/ui-core": "^2.1.
|
|
216
|
+
"@djangocfg/typescript-config": "^2.1.377",
|
|
217
|
+
"@djangocfg/ui-core": "^2.1.377",
|
|
218
218
|
"@types/lodash-es": "^4.17.12",
|
|
219
219
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
220
220
|
"@types/node": "^24.7.2",
|
package/src/tools/Chat/README.md
CHANGED
|
@@ -342,6 +342,48 @@ Options:
|
|
|
342
342
|
The hook only fires on the `true → false` edge — flipping `enabled`
|
|
343
343
|
mid-stream won't steal focus.
|
|
344
344
|
|
|
345
|
+
## Draft sanitation (pre-submit)
|
|
346
|
+
|
|
347
|
+
`useChatComposer.submit()` cleans the draft before handing it to your
|
|
348
|
+
`onSubmit` — mirroring what ChatGPT / Claude / Telegram ship. The
|
|
349
|
+
helper is also exported standalone for custom composers:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
import { sanitizeDraft, isSubmittableDraft } from '@djangocfg/ui-tools'
|
|
353
|
+
|
|
354
|
+
sanitizeDraft(' \n\nhello world\n ') // → 'hello world'
|
|
355
|
+
isSubmittableDraft(' \n ') // → false
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Rules (intentionally minimal)
|
|
359
|
+
|
|
360
|
+
| Rule | Action | Why |
|
|
361
|
+
|---|---|---|
|
|
362
|
+
| Trim outer whitespace | ✅ | Stray newlines/spaces at the edges are never intentional. |
|
|
363
|
+
| Normalise `\r\n` / `\r` → `\n` | ✅ | Deterministic shape for LLM tokeniser + markdown render. |
|
|
364
|
+
| Strip ZWSP / ZWNJ / ZWJ / BOM | ✅ | Pasted from rich web pages, invisible, break tokenisation. |
|
|
365
|
+
| **Collapse internal whitespace runs** | ❌ | Would mangle code indentation. |
|
|
366
|
+
| **Cap consecutive blank lines** | ❌ | Could be intentional separator (markdown / structured prompt). |
|
|
367
|
+
| **Strip bidi overrides** (LRM/RLM/U+202A..E) | ❌ | Legitimately used in Arabic/Hebrew RTL content. |
|
|
368
|
+
| Touch tabs vs spaces, emoji, mentions, URLs | ❌ | Passthrough. |
|
|
369
|
+
|
|
370
|
+
Idempotent: `sanitizeDraft(sanitizeDraft(x)) === sanitizeDraft(x)`.
|
|
371
|
+
|
|
372
|
+
### Escape hatch — `preserveExactValue`
|
|
373
|
+
|
|
374
|
+
Niche flows (clipboard inspector, raw-prompt debug tool) want
|
|
375
|
+
byte-perfect passthrough. Opt out per-composer:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const composer = useChatComposer({
|
|
379
|
+
onSubmit,
|
|
380
|
+
preserveExactValue: true, // skip sanitation; raw textarea value goes through
|
|
381
|
+
})
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`canSubmit` still gates on `value.trim().length > 0` in this mode —
|
|
385
|
+
an empty message is rarely intentional even when sanitation is off.
|
|
386
|
+
|
|
345
387
|
## Attachment renderers (registry)
|
|
346
388
|
|
|
347
389
|
`<Attachments>` and `<ChatRoot>` accept a per-type renderer map. Default tile is used when no renderer matches. Plug in heavy viewers (`LazyAudioPlayer`, `LazyImageViewer`, `LazyMap`) host-side without forcing `ui-tools/Chat` to depend on them.
|
|
@@ -532,6 +574,9 @@ type ToolPayloadMatcher, type ToolPayloadFallback
|
|
|
532
574
|
// Lightbox helpers
|
|
533
575
|
collectImageAttachments
|
|
534
576
|
|
|
577
|
+
// Draft sanitation (pre-submit cleanup; see "Draft sanitation" above)
|
|
578
|
+
sanitizeDraft, isSubmittableDraft
|
|
579
|
+
|
|
535
580
|
// Context
|
|
536
581
|
ChatProvider, useChatContext, useChatContextOptional,
|
|
537
582
|
type ComposerHandle
|
|
@@ -563,6 +608,12 @@ LazyChat
|
|
|
563
608
|
| `↓` | Recall next |
|
|
564
609
|
| `Esc` | (host-bound) cancel stream |
|
|
565
610
|
|
|
611
|
+
> **Custom composers built on `MarkdownEditor`:** pass `onSubmit` to
|
|
612
|
+
> the editor for the same behaviour. A React `onKeyDown` wrapper does
|
|
613
|
+
> NOT reliably intercept Enter before Tiptap commits the HardBreak
|
|
614
|
+
> — see `MarkdownEditor/README.md#submit-on-enter` for the full
|
|
615
|
+
> incident write-up and the keymap-extension fix.
|
|
616
|
+
|
|
566
617
|
## Performance
|
|
567
618
|
|
|
568
619
|
- **Token coalescing.** `createTokenBuffer` aggregates stream chunks within ~16ms before dispatching → ≤1 render per frame.
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
|
|
14
14
|
import type { ChatAttachment } from '../types';
|
|
15
15
|
import { LIMITS } from '../config';
|
|
16
|
+
import { sanitizeDraft } from '../utils/sanitizeDraft';
|
|
16
17
|
|
|
17
18
|
export interface UseChatComposerOptions {
|
|
18
19
|
onSubmit: (content: string, attachments: ChatAttachment[]) => void | Promise<void>;
|
|
@@ -34,6 +35,14 @@ export interface UseChatComposerOptions {
|
|
|
34
35
|
* exactly as before. Plan64.
|
|
35
36
|
*/
|
|
36
37
|
persistKey?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Skip pre-submit draft sanitation (trim + line-ending normalise +
|
|
40
|
+
* zero-width strip). Default `false` — sanitation matches what
|
|
41
|
+
* ChatGPT / Claude / Telegram ship and is what consumers want 99% of
|
|
42
|
+
* the time. Set to `true` for niche flows that need byte-perfect
|
|
43
|
+
* passthrough (clipboard inspector, raw-prompt debug tool).
|
|
44
|
+
*/
|
|
45
|
+
preserveExactValue?: boolean;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export interface UseChatComposerReturn {
|
|
@@ -73,6 +82,7 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
73
82
|
history = { enabled: true, size: LIMITS.composerHistorySize },
|
|
74
83
|
onPasteFiles,
|
|
75
84
|
persistKey,
|
|
85
|
+
preserveExactValue = false,
|
|
76
86
|
} = options;
|
|
77
87
|
|
|
78
88
|
// Hydrate draft from sessionStorage on mount when a key is provided.
|
|
@@ -135,26 +145,39 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
135
145
|
}, []);
|
|
136
146
|
|
|
137
147
|
const submit = useCallback(async () => {
|
|
138
|
-
|
|
139
|
-
|
|
148
|
+
// Sanitise BEFORE the empty-guard so a draft of only whitespace
|
|
149
|
+
// (` \n\n `) is treated as empty — saves a round trip for
|
|
150
|
+
// "send" attempts that would produce nothing.
|
|
151
|
+
//
|
|
152
|
+
// sanitizeDraft is intentionally conservative: trim outer
|
|
153
|
+
// whitespace, normalise line endings, strip zero-width chars.
|
|
154
|
+
// It does NOT collapse internal whitespace or cap blank lines
|
|
155
|
+
// (would break code indentation / intentional separators). See
|
|
156
|
+
// utils/sanitizeDraft.ts for the full ruleset + rationale.
|
|
157
|
+
//
|
|
158
|
+
// The cleaned text is what reaches `onSubmit` (matches ChatGPT /
|
|
159
|
+
// Claude / Telegram behaviour — the bubble shows what the user
|
|
160
|
+
// last saw, sans accidental trailing whitespace). Niche callers
|
|
161
|
+
// can opt out via `preserveExactValue`.
|
|
162
|
+
const cleaned = preserveExactValue ? value : sanitizeDraft(value);
|
|
163
|
+
if ((!cleaned && attachments.length === 0) || isSubmitting || disabled) return;
|
|
140
164
|
setIsSubmitting(true);
|
|
141
165
|
try {
|
|
142
|
-
if (history.enabled !== false &&
|
|
166
|
+
if (history.enabled !== false && cleaned) {
|
|
143
167
|
const buf = historyRef.current.items;
|
|
144
|
-
if (buf[buf.length - 1] !==
|
|
145
|
-
buf.push(
|
|
168
|
+
if (buf[buf.length - 1] !== cleaned) {
|
|
169
|
+
buf.push(cleaned);
|
|
146
170
|
if (buf.length > (history.size ?? LIMITS.composerHistorySize)) buf.shift();
|
|
147
171
|
}
|
|
148
172
|
historyRef.current.index = -1;
|
|
149
173
|
}
|
|
150
174
|
const snapshot = [...attachments];
|
|
151
|
-
const text = value;
|
|
152
175
|
reset();
|
|
153
|
-
await onSubmit(
|
|
176
|
+
await onSubmit(cleaned, snapshot);
|
|
154
177
|
} finally {
|
|
155
178
|
setIsSubmitting(false);
|
|
156
179
|
}
|
|
157
|
-
}, [value, attachments, isSubmitting, disabled, history, onSubmit, reset]);
|
|
180
|
+
}, [value, attachments, isSubmitting, disabled, history, onSubmit, reset, preserveExactValue]);
|
|
158
181
|
|
|
159
182
|
const addAttachment = useCallback(
|
|
160
183
|
(a: ChatAttachment) => {
|
|
@@ -236,8 +259,17 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
236
259
|
[onPasteFiles],
|
|
237
260
|
);
|
|
238
261
|
|
|
262
|
+
// canSubmit mirrors what submit() will actually do — gate the Send
|
|
263
|
+
// button on the post-sanitation result so a whitespace-only draft
|
|
264
|
+
// renders Send as disabled (no false-hope affordance).
|
|
265
|
+
// preserveExactValue callers fall back to raw .trim() — they
|
|
266
|
+
// opted out of sanitation, but we still don't enable Send on a
|
|
267
|
+
// pure-whitespace draft (an empty message is rarely intentional).
|
|
239
268
|
const canSubmit =
|
|
240
|
-
!disabled && !isSubmitting && (
|
|
269
|
+
!disabled && !isSubmitting && (
|
|
270
|
+
(preserveExactValue ? value.trim().length : sanitizeDraft(value).length) > 0
|
|
271
|
+
|| attachments.length > 0
|
|
272
|
+
);
|
|
241
273
|
|
|
242
274
|
return {
|
|
243
275
|
value,
|
package/src/tools/Chat/index.ts
CHANGED
|
@@ -113,6 +113,11 @@ export {
|
|
|
113
113
|
export { useChatLightbox, type UseChatLightboxReturn, type ChatLightboxState } from './hooks';
|
|
114
114
|
export { collectImageAttachments } from './utils/collectImageAttachments';
|
|
115
115
|
|
|
116
|
+
// Draft sanitation — trim, collapse runs, strip zero-width chars.
|
|
117
|
+
// Wire into your composer's submit handler / Send-button gate so
|
|
118
|
+
// empty-after-cleanup drafts don't fire bogus messages.
|
|
119
|
+
export { sanitizeDraft, isSubmittableDraft } from './utils/sanitizeDraft';
|
|
120
|
+
|
|
116
121
|
// Dev logger (consola-based, namespace "chat:*")
|
|
117
122
|
export { getChatLogger, type ChatLogger, type ChatLogScope } from './core/logger';
|
|
118
123
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sanitizeDraft — minimal pre-submit cleanup for chat-composer drafts.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the conservative behaviour ChatGPT / Claude / Telegram
|
|
5
|
+
* actually ship: clean the obvious junk the user didn't intend to
|
|
6
|
+
* send, touch nothing that *could* be intentional.
|
|
7
|
+
*
|
|
8
|
+
* **What we DO touch:**
|
|
9
|
+
*
|
|
10
|
+
* 1. Trim leading/trailing whitespace (spaces, tabs, newlines,
|
|
11
|
+
* NBSP). The user typing `\n\n hello \n` meant `hello`.
|
|
12
|
+
* 2. Normalise line endings — `\r\n` / `\r` → `\n`. Pasted Windows
|
|
13
|
+
* / old-mac text gets the same internal shape, so the LLM
|
|
14
|
+
* tokeniser and markdown renderer see one canonical form.
|
|
15
|
+
* 3. Strip zero-width / invisible characters that web-paste
|
|
16
|
+
* smuggles in: ZWSP (U+200B), ZWNJ (U+200C), ZWJ (U+200D),
|
|
17
|
+
* BOM / ZWNBSP (U+FEFF). They're invisible, break LLM
|
|
18
|
+
* tokenisation, and the user never meant to type them.
|
|
19
|
+
*
|
|
20
|
+
* **What we DO NOT touch (and why):**
|
|
21
|
+
*
|
|
22
|
+
* - **Internal whitespace runs** (3+ spaces, tabs, blank lines).
|
|
23
|
+
* Code indentation depends on these. ChatGPT preserves them as
|
|
24
|
+
* typed — " if (x):\n return" stays four-space-indented.
|
|
25
|
+
* Collapsing them is the path to subtly broken code snippets.
|
|
26
|
+
*
|
|
27
|
+
* - **Bidi override marks** (U+200E LRM, U+200F RLM, U+202A..U+202E).
|
|
28
|
+
* Legitimately used in Arabic / Hebrew / mixed-direction text.
|
|
29
|
+
* Stripping silently breaks RTL users. If a specific deployment
|
|
30
|
+
* wants to block them as a security measure, do it at that layer
|
|
31
|
+
* with explicit user-visible feedback.
|
|
32
|
+
*
|
|
33
|
+
* - **Tabs vs spaces** beyond rule 1. Could be either code or
|
|
34
|
+
* prose; without parsing markdown we can't tell.
|
|
35
|
+
*
|
|
36
|
+
* - **Emoji, mentions, URLs, code spans** — passthrough text.
|
|
37
|
+
*
|
|
38
|
+
* The function is intentionally tiny — every rule earns its keep
|
|
39
|
+
* with a concrete "user pasted X from Y, got nonsense" story.
|
|
40
|
+
*
|
|
41
|
+
* **Idempotent**: `sanitizeDraft(sanitizeDraft(x)) === sanitizeDraft(x)`.
|
|
42
|
+
*/
|
|
43
|
+
export function sanitizeDraft(input: string): string {
|
|
44
|
+
if (!input) return '';
|
|
45
|
+
|
|
46
|
+
// Strip zero-width invisibles. Done FIRST so the trim below sees
|
|
47
|
+
// the real content edges — a stray ZWSP at the start would
|
|
48
|
+
// otherwise count as non-whitespace and survive the trim.
|
|
49
|
+
// U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+FEFF BOM/ZWNBSP.
|
|
50
|
+
let s = input.replace(/[]/g, '');
|
|
51
|
+
|
|
52
|
+
// Normalise line endings.
|
|
53
|
+
s = s.replace(/\r\n?/g, '\n');
|
|
54
|
+
|
|
55
|
+
// Trim outer whitespace (includes \n, \t, NBSP via String.trim).
|
|
56
|
+
s = s.trim();
|
|
57
|
+
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convenience predicate: true when the draft is non-empty AFTER
|
|
63
|
+
* sanitation. Use to gate Send buttons / Enter submits so an empty
|
|
64
|
+
* or whitespace-only draft never produces a real message.
|
|
65
|
+
*
|
|
66
|
+
* Cheaper than sanitizeDraft(input).length > 0 only marginally —
|
|
67
|
+
* we still allocate the cleaned string. Kept as a named helper for
|
|
68
|
+
* call-site clarity.
|
|
69
|
+
*/
|
|
70
|
+
export function isSubmittableDraft(input: string): boolean {
|
|
71
|
+
return sanitizeDraft(input).length > 0;
|
|
72
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'lucide-react';
|
|
14
14
|
import { createMentionSuggestion } from './createMentionSuggestion';
|
|
15
15
|
import { mentionPresets } from './mentionPresets';
|
|
16
|
+
import { SubmitOnEnter } from './submitOnEnter';
|
|
16
17
|
import type { MentionAttrs, MentionConfig } from './types';
|
|
17
18
|
import './styles.css';
|
|
18
19
|
|
|
@@ -68,6 +69,25 @@ export interface MarkdownEditorProps {
|
|
|
68
69
|
mentions?: MentionConfig;
|
|
69
70
|
/** Called when mentioned IDs change */
|
|
70
71
|
onMentionIdsChange?: (ids: string[]) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Called when the user presses Enter (without Shift, no IME
|
|
74
|
+
* composition, no mention popover open). When set, Enter submits
|
|
75
|
+
* and Shift+Enter inserts a newline — ChatGPT / Telegram chat
|
|
76
|
+
* behaviour. When omitted, Enter behaves as Tiptap default
|
|
77
|
+
* (HardBreak).
|
|
78
|
+
*
|
|
79
|
+
* Implementation lives in `submitOnEnter.ts` — it's a Tiptap
|
|
80
|
+
* keymap extension, NOT a React wrapper handler. Wrapper-level
|
|
81
|
+
* onKeyDown fires AFTER ProseMirror's keymap commits HardBreak in
|
|
82
|
+
* the same tick; routing through the extension lets us intercept
|
|
83
|
+
* before HardBreak runs.
|
|
84
|
+
*
|
|
85
|
+
* Return value (optional): truthy / undefined = consume the key
|
|
86
|
+
* (default). Return `false` from onSubmit to let Tiptap fall
|
|
87
|
+
* through to HardBreak — useful for guards like "don't submit an
|
|
88
|
+
* empty draft".
|
|
89
|
+
*/
|
|
90
|
+
onSubmit?: () => boolean | void;
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
// ── Component ──
|
|
@@ -82,7 +102,15 @@ export function MarkdownEditor({
|
|
|
82
102
|
showToolbar = true,
|
|
83
103
|
mentions,
|
|
84
104
|
onMentionIdsChange,
|
|
105
|
+
onSubmit,
|
|
85
106
|
}: MarkdownEditorProps) {
|
|
107
|
+
// Keep the latest onSubmit in a ref so the Tiptap extension's
|
|
108
|
+
// keymap closure always calls the freshest handler — Tiptap's
|
|
109
|
+
// useEditor initialises extensions ONCE on first render. Without
|
|
110
|
+
// the ref the extension would call a stale onSubmit (e.g. one
|
|
111
|
+
// that references an outdated `value`).
|
|
112
|
+
const onSubmitRef = useRef(onSubmit);
|
|
113
|
+
onSubmitRef.current = onSubmit;
|
|
86
114
|
const isExternalUpdate = useRef(false);
|
|
87
115
|
|
|
88
116
|
// ── Dev-mode trap detector ──
|
|
@@ -115,6 +143,18 @@ export function MarkdownEditor({
|
|
|
115
143
|
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
|
116
144
|
Placeholder.configure({ placeholder }),
|
|
117
145
|
Markdown,
|
|
146
|
+
// SubmitOnEnter — when the consumer wired an onSubmit, intercept
|
|
147
|
+
// Enter at the keymap level (before StarterKit's HardBreak).
|
|
148
|
+
// The extension calls through `onSubmitRef.current` so handler
|
|
149
|
+
// identity changes don't require an editor rebuild. See
|
|
150
|
+
// submitOnEnter.ts for the keymap-vs-wrapper-handler rationale.
|
|
151
|
+
SubmitOnEnter.configure({
|
|
152
|
+
onSubmit: () => {
|
|
153
|
+
const h = onSubmitRef.current;
|
|
154
|
+
if (!h) return false; // no handler → let Tiptap insert HardBreak
|
|
155
|
+
return h();
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
118
158
|
];
|
|
119
159
|
|
|
120
160
|
if (mentions) {
|
|
@@ -58,6 +58,7 @@ import '@djangocfg/ui-tools/dist.css';
|
|
|
58
58
|
| `showToolbar` | `boolean` | `true` | Show formatting toolbar |
|
|
59
59
|
| `mentions` | `MentionConfig` | — | `@`-mention autocomplete config |
|
|
60
60
|
| `onMentionIdsChange` | `(ids: string[]) => void` | — | Called when mentioned IDs change |
|
|
61
|
+
| `onSubmit` | `() => boolean \| void` | — | Enter handler — when set, Enter submits and Shift+Enter inserts a newline (ChatGPT / Telegram chat behaviour). Return `false` to fall back to default HardBreak. See [Submit on Enter](#submit-on-enter) below. |
|
|
61
62
|
|
|
62
63
|
### `MentionConfig` fields
|
|
63
64
|
|
|
@@ -142,6 +143,39 @@ Either `id` or `label` may be empty strings if upstream config didn't populate t
|
|
|
142
143
|
|
|
143
144
|
> 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.
|
|
144
145
|
|
|
146
|
+
## Submit on Enter
|
|
147
|
+
|
|
148
|
+
For chat composers you usually want **Enter = send**, **Shift+Enter = newline** — ChatGPT / Telegram / Slack behaviour. Pass an `onSubmit` and that's what you get:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
<MarkdownEditor
|
|
152
|
+
value={text}
|
|
153
|
+
onChange={setText}
|
|
154
|
+
onSubmit={() => {
|
|
155
|
+
if (!text.trim()) return false // fall back to HardBreak
|
|
156
|
+
void send(text)
|
|
157
|
+
// returning undefined (or true) consumes the key — newline isn't inserted
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### How it works (and why a Tiptap extension, not a React onKeyDown)
|
|
163
|
+
|
|
164
|
+
The implementation lives in `submitOnEnter.ts` — a Tiptap `Extension` that registers a keyboard shortcut via `addKeyboardShortcuts`. It runs **inside ProseMirror's keymap pipeline at higher priority than StarterKit's HardBreak**, so we intercept Enter **before** the hard-break transaction is dispatched.
|
|
165
|
+
|
|
166
|
+
A naïve wrapper handler (`<div onKeyDownCapture={...}>` calling `preventDefault`) does NOT work reliably: ProseMirror's keymap is a plugin inside the editor, which fires its handler in the same event tick as the React capture phase but **commits the HardBreak transaction before React's stopPropagation gets a chance to matter**. The user sees a hard-break flash in, then the next Enter submits — the "first Enter inserts newline" bug we shipped before this extension landed.
|
|
167
|
+
|
|
168
|
+
### Behaviour details
|
|
169
|
+
|
|
170
|
+
| Key | Behaviour |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `Enter` | Calls `onSubmit()`. Returns `true`/`undefined` ⇒ consume. Returns `false` ⇒ fall through to HardBreak. |
|
|
173
|
+
| `Shift+Enter` | Always inserts a newline. Bound to `() => false` so the chain falls through cleanly. |
|
|
174
|
+
| Mention popover open | Enter is given to the suggestion plugin (it picks the active item) — detected by querying `.markdown-mention-list`. |
|
|
175
|
+
| IME composition | Native browser composition events are not intercepted (Tiptap handles them upstream). |
|
|
176
|
+
|
|
177
|
+
The handler is captured via a ref inside `MarkdownEditor`, so swapping `onSubmit` between renders is safe — the latest closure always fires, no editor rebuild needed.
|
|
178
|
+
|
|
145
179
|
## Dependencies
|
|
146
180
|
|
|
147
181
|
All Tiptap packages and `@floating-ui/dom` are direct dependencies — no extra installs needed.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SubmitOnEnter — Tiptap extension that hooks ProseMirror's keymap
|
|
2
|
+
// before StarterKit's HardBreak so Enter consistently submits and
|
|
3
|
+
// Shift+Enter inserts a newline (ChatGPT / Telegram behaviour).
|
|
4
|
+
//
|
|
5
|
+
// Why an extension and not a wrapper `onKeyDown` handler:
|
|
6
|
+
//
|
|
7
|
+
// Tiptap registers its keymaps inside the ProseMirror keymap
|
|
8
|
+
// plugin, which runs in the ProseMirror dispatch pipeline. A React
|
|
9
|
+
// `onKeyDown` / `onKeyDownCapture` on a wrapper div fires AFTER
|
|
10
|
+
// ProseMirror has already committed the HardBreak transaction in
|
|
11
|
+
// the same event tick — so calling `preventDefault()` there is too
|
|
12
|
+
// late: the user sees a hard-break flash in, the React handler
|
|
13
|
+
// then runs its preventDefault, and the next Enter submits.
|
|
14
|
+
//
|
|
15
|
+
// Registering the keybinding via `addKeyboardShortcuts` puts us in
|
|
16
|
+
// the same keymap pipeline at a higher priority than StarterKit's
|
|
17
|
+
// default Enter binding, so we intercept before HardBreak runs.
|
|
18
|
+
//
|
|
19
|
+
// The handler also respects IME composition and the mention popover
|
|
20
|
+
// (Tiptap's suggestion plugin captures Enter when its popover is
|
|
21
|
+
// open — we mirror that by checking `.markdown-mention-list` in the
|
|
22
|
+
// DOM; same predicate the wrapper used).
|
|
23
|
+
|
|
24
|
+
import { Extension } from '@tiptap/core';
|
|
25
|
+
|
|
26
|
+
export interface SubmitOnEnterOptions {
|
|
27
|
+
/** Fired when Enter is pressed without Shift, no IME composition,
|
|
28
|
+
* and no mention popover open. Return `true` to consume the key
|
|
29
|
+
* (default behaviour), `false` to let ProseMirror handle it (i.e.
|
|
30
|
+
* fall back to HardBreak). */
|
|
31
|
+
onSubmit: () => boolean | void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SubmitOnEnter = Extension.create<SubmitOnEnterOptions>({
|
|
35
|
+
name: 'submitOnEnter',
|
|
36
|
+
|
|
37
|
+
addOptions() {
|
|
38
|
+
return {
|
|
39
|
+
// Default no-op — explicit consumer must override. We never
|
|
40
|
+
// intercept Enter unless an onSubmit is wired, so leaving the
|
|
41
|
+
// extension installed with no handler is safe.
|
|
42
|
+
onSubmit: () => false,
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
addKeyboardShortcuts() {
|
|
47
|
+
return {
|
|
48
|
+
Enter: () => {
|
|
49
|
+
// Mention suggestion popover owns Enter while open.
|
|
50
|
+
// The suggestion plugin renders the list with this class
|
|
51
|
+
// (see createMentionSuggestion.ts).
|
|
52
|
+
if (typeof document !== 'undefined' && document.querySelector('.markdown-mention-list')) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const result = this.options.onSubmit();
|
|
56
|
+
// Default: consume the key (true). Only let it fall through
|
|
57
|
+
// to HardBreak if onSubmit explicitly returned false.
|
|
58
|
+
return result !== false;
|
|
59
|
+
},
|
|
60
|
+
// Shift+Enter — always insert a newline. Tiptap's StarterKit
|
|
61
|
+
// already binds this to HardBreak; we re-bind to `false` (not
|
|
62
|
+
// handled) so the chain falls through cleanly even if other
|
|
63
|
+
// extensions try to grab Shift+Enter.
|
|
64
|
+
'Shift-Enter': () => false,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
var chunkUVIFD3TH_cjs = require('./chunk-UVIFD3TH.cjs');
|
|
4
|
-
require('./chunk-XACCHZH2.cjs');
|
|
5
|
-
require('./chunk-OLISEQHS.cjs');
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Object.defineProperty(exports, "ChatRoot", {
|
|
10
|
-
enumerable: true,
|
|
11
|
-
get: function () { return chunkUVIFD3TH_cjs.ChatRoot; }
|
|
12
|
-
});
|
|
13
|
-
//# sourceMappingURL=ChatRoot-F5XXERXU.cjs.map
|
|
14
|
-
//# sourceMappingURL=ChatRoot-F5XXERXU.cjs.map
|