@djangocfg/ui-tools 2.1.376 → 2.1.378

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.376",
3
+ "version": "2.1.378",
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.376",
160
- "@djangocfg/ui-core": "^2.1.376",
159
+ "@djangocfg/i18n": "^2.1.378",
160
+ "@djangocfg/ui-core": "^2.1.378",
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.376",
214
+ "@djangocfg/i18n": "^2.1.378",
215
215
  "@djangocfg/playground": "workspace:*",
216
- "@djangocfg/typescript-config": "^2.1.376",
217
- "@djangocfg/ui-core": "^2.1.376",
216
+ "@djangocfg/typescript-config": "^2.1.378",
217
+ "@djangocfg/ui-core": "^2.1.378",
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",
@@ -1283,6 +1283,107 @@ export const WailsLikeVirtualization = () => {
1283
1283
  );
1284
1284
  };
1285
1285
 
1286
+ // ---------------------------------------------------------------------------
1287
+ // 16) WithRenderAfterCalls — rich UI outside tool panels
1288
+ // Demonstrates: renderAfterCalls, hideToolCalls, renderToolCall
1289
+ // ---------------------------------------------------------------------------
1290
+
1291
+ const SEARCH_SEQUENCE: ChatStreamEvent[] = [
1292
+ { type: 'chunk', delta: 'Searching the catalog for you.\n\n' },
1293
+ {
1294
+ type: 'tool_call_start',
1295
+ toolId: 'v1',
1296
+ name: 'search_vehicles',
1297
+ input: { make: 'BMW', year_min: 2022 },
1298
+ },
1299
+ { type: 'tool_call_delta', toolId: 'v1', delta: 'querying…\n' },
1300
+ {
1301
+ type: 'tool_call_end',
1302
+ toolId: 'v1',
1303
+ output: {
1304
+ items: [
1305
+ { id: 'car-001', make: 'BMW', model: '5 Series', year: 2023, price: 45000 },
1306
+ { id: 'car-002', make: 'BMW', model: 'X5', year: 2022, price: 72000 },
1307
+ ],
1308
+ total: 2,
1309
+ },
1310
+ status: 'success',
1311
+ },
1312
+ { type: 'chunk', delta: 'Found 2 vehicles — see the cards below.' },
1313
+ { type: 'message_end' },
1314
+ ];
1315
+
1316
+ function MockVehicleCards({ calls }: { calls: import('./types').ChatToolCall[] }) {
1317
+ const items: Array<{ id: string; make: string; model: string; year: number; price: number }> = [];
1318
+ for (const call of calls) {
1319
+ if (call.status !== 'success' || call.name !== 'search_vehicles') continue;
1320
+ const out = call.output as { items?: typeof items } | null;
1321
+ for (const item of out?.items ?? []) items.push(item);
1322
+ }
1323
+ if (items.length === 0) return null;
1324
+ return (
1325
+ <div className="mt-2 space-y-1.5">
1326
+ {items.map((v) => (
1327
+ <div
1328
+ key={v.id}
1329
+ className="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs"
1330
+ >
1331
+ <div className="size-10 shrink-0 rounded bg-muted" />
1332
+ <div className="min-w-0 flex-1">
1333
+ <p className="font-semibold">{v.make} {v.model}</p>
1334
+ <p className="text-muted-foreground">{v.year}</p>
1335
+ </div>
1336
+ <span className="font-mono text-sm font-semibold">${v.price.toLocaleString()}</span>
1337
+ </div>
1338
+ ))}
1339
+ </div>
1340
+ );
1341
+ }
1342
+
1343
+ export const WithRenderAfterCalls = () => {
1344
+ const [hide] = useBoolean('hide-panels', { defaultValue: false, label: 'hideToolCalls (hide accordion panels)' });
1345
+ const [useCustom] = useBoolean('custom-call', { defaultValue: false, label: 'renderToolCall (custom per-call renderer)' });
1346
+
1347
+ const transport = useMemo(
1348
+ () => createMockTransport({ replies: [SEARCH_SEQUENCE, SEARCH_SEQUENCE], latencyMs: 40 }),
1349
+ [],
1350
+ );
1351
+
1352
+ const toolCallsProps = useMemo(
1353
+ () => ({
1354
+ hideToolCalls: hide,
1355
+ renderAfterCalls: (calls: import('./types').ChatToolCall[]) => <MockVehicleCards calls={calls} />,
1356
+ ...(useCustom
1357
+ ? {
1358
+ renderToolCall: (call: import('./types').ChatToolCall) => (
1359
+ <div className="flex items-center gap-2 rounded border border-border bg-muted/40 px-2 py-1 text-xs">
1360
+ <span className="font-mono text-muted-foreground">{call.name}</span>
1361
+ <span className={`ml-auto rounded px-1 py-0.5 text-[10px] font-medium ${
1362
+ call.status === 'success' ? 'bg-emerald-500/10 text-emerald-600' : 'bg-amber-500/10 text-amber-600'
1363
+ }`}>{call.status}</span>
1364
+ </div>
1365
+ ),
1366
+ }
1367
+ : {}),
1368
+ }),
1369
+ [hide, useCustom],
1370
+ );
1371
+
1372
+ return (
1373
+ <Frame h={560}>
1374
+ <ChatRoot
1375
+ transport={transport}
1376
+ config={{
1377
+ greeting: 'Vehicle search demo',
1378
+ placeholder: 'Ask "find BMW vehicles"…',
1379
+ suggestions: [{ label: 'Find BMW vehicles', prompt: 'Find BMW vehicles from 2022' }],
1380
+ }}
1381
+ toolCallsProps={toolCallsProps}
1382
+ />
1383
+ </Frame>
1384
+ );
1385
+ };
1386
+
1286
1387
  // ---------------------------------------------------------------------------
1287
1388
  // AutoFocusOnStreamEnd — refocus composer the moment a reply lands
1288
1389
  // ---------------------------------------------------------------------------
@@ -168,6 +168,68 @@ import { LazyJsonTree } from '@djangocfg/ui-tools/json-tree';
168
168
 
169
169
  We don't import `LazyJsonTree` automatically — keeping the chat dep-light. The host opts in.
170
170
 
171
+ ### ToolCalls — rich UI after panels (`renderAfterCalls`)
172
+
173
+ `renderAfterCalls` renders **outside and below** all collapsible panels — always visible, regardless of whether panels are expanded or hidden. It receives the full `calls` array so you can aggregate across multiple tool calls (e.g. collect all vehicle IDs from `search_vehicles` + `get_vehicle` and render cards).
174
+
175
+ ```tsx
176
+ <ChatRoot
177
+ toolCallsProps={{
178
+ // Rich UI below all panels — always visible, never inside collapsed accordions
179
+ renderAfterCalls: (calls) => <VehicleCardList calls={calls} />,
180
+ }}
181
+ />
182
+ ```
183
+
184
+ #### hideToolCalls — show only rich UI, no raw panels
185
+
186
+ Set `hideToolCalls={true}` to suppress accordion panels entirely while `renderAfterCalls` still renders. Use when you want clean product UI without raw tool payloads visible.
187
+
188
+ ```tsx
189
+ <ChatRoot
190
+ toolCallsProps={{
191
+ hideToolCalls: true, // hides accordion panels
192
+ renderAfterCalls: (calls) => <VehicleCards calls={calls} />, // still runs
193
+ }}
194
+ />
195
+ ```
196
+
197
+ `hideToolCalls` only affects the accordion panels — `renderAfterCalls` is always independent.
198
+
199
+ #### renderToolCall — custom per-call panel renderer
200
+
201
+ Replace the default `<ToolCallItem>` accordion with your own UI. Return `null` to suppress a specific call.
202
+
203
+ ```tsx
204
+ <ChatRoot
205
+ toolCallsProps={{
206
+ renderToolCall: (call) => (
207
+ <div className="flex items-center gap-2 rounded border px-2 py-1 text-xs">
208
+ <span className="font-mono">{call.name}</span>
209
+ <span className="ml-auto">{call.status}</span>
210
+ </div>
211
+ ),
212
+ }}
213
+ />
214
+ ```
215
+
216
+ #### Full example — vehicle cards from tool output
217
+
218
+ ```tsx
219
+ import { buildVamcarToolCallsProps } from './chat/toolPayloads';
220
+
221
+ // toolPayloads.tsx:
222
+ export function buildVamcarToolCallsProps(): Omit<ToolCallsProps, 'calls'> {
223
+ return {
224
+ renderAfterCalls: (calls) => <VehicleCardsFromCalls calls={calls} />,
225
+ };
226
+ }
227
+
228
+ // In your chat component:
229
+ const toolCallsProps = useMemo(() => buildVamcarToolCallsProps(), []);
230
+ <ChatRoot transport={transport} toolCallsProps={toolCallsProps} />
231
+ ```
232
+
171
233
  ## Personas (user & assistant identity)
172
234
 
173
235
  Pass identities once on `<ChatRoot config>` — every bubble gets the right name, avatar and `aria-label`. Outgoing user messages get the `sender` field auto-stamped from `config.user`.
@@ -342,6 +404,48 @@ Options:
342
404
  The hook only fires on the `true → false` edge — flipping `enabled`
343
405
  mid-stream won't steal focus.
344
406
 
407
+ ## Draft sanitation (pre-submit)
408
+
409
+ `useChatComposer.submit()` cleans the draft before handing it to your
410
+ `onSubmit` — mirroring what ChatGPT / Claude / Telegram ship. The
411
+ helper is also exported standalone for custom composers:
412
+
413
+ ```ts
414
+ import { sanitizeDraft, isSubmittableDraft } from '@djangocfg/ui-tools'
415
+
416
+ sanitizeDraft(' \n\nhello world​\n ') // → 'hello world'
417
+ isSubmittableDraft(' \n ') // → false
418
+ ```
419
+
420
+ ### Rules (intentionally minimal)
421
+
422
+ | Rule | Action | Why |
423
+ |---|---|---|
424
+ | Trim outer whitespace | ✅ | Stray newlines/spaces at the edges are never intentional. |
425
+ | Normalise `\r\n` / `\r` → `\n` | ✅ | Deterministic shape for LLM tokeniser + markdown render. |
426
+ | Strip ZWSP / ZWNJ / ZWJ / BOM | ✅ | Pasted from rich web pages, invisible, break tokenisation. |
427
+ | **Collapse internal whitespace runs** | ❌ | Would mangle code indentation. |
428
+ | **Cap consecutive blank lines** | ❌ | Could be intentional separator (markdown / structured prompt). |
429
+ | **Strip bidi overrides** (LRM/RLM/U+202A..E) | ❌ | Legitimately used in Arabic/Hebrew RTL content. |
430
+ | Touch tabs vs spaces, emoji, mentions, URLs | ❌ | Passthrough. |
431
+
432
+ Idempotent: `sanitizeDraft(sanitizeDraft(x)) === sanitizeDraft(x)`.
433
+
434
+ ### Escape hatch — `preserveExactValue`
435
+
436
+ Niche flows (clipboard inspector, raw-prompt debug tool) want
437
+ byte-perfect passthrough. Opt out per-composer:
438
+
439
+ ```ts
440
+ const composer = useChatComposer({
441
+ onSubmit,
442
+ preserveExactValue: true, // skip sanitation; raw textarea value goes through
443
+ })
444
+ ```
445
+
446
+ `canSubmit` still gates on `value.trim().length > 0` in this mode —
447
+ an empty message is rarely intentional even when sanitation is off.
448
+
345
449
  ## Attachment renderers (registry)
346
450
 
347
451
  `<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.
@@ -527,11 +631,15 @@ useChatAudioPrefs
527
631
  // Tool-payload dispatch
528
632
  dispatchToolPayload, isPlainObject, isLatLng,
529
633
  isGeoJSONFeatureCollection, isStringValue,
530
- type ToolPayloadMatcher, type ToolPayloadFallback
634
+ type ToolPayloadMatcher, type ToolPayloadFallback,
635
+ type ToolPayloadKind, type ToolCallsProps
531
636
 
532
637
  // Lightbox helpers
533
638
  collectImageAttachments
534
639
 
640
+ // Draft sanitation (pre-submit cleanup; see "Draft sanitation" above)
641
+ sanitizeDraft, isSubmittableDraft
642
+
535
643
  // Context
536
644
  ChatProvider, useChatContext, useChatContextOptional,
537
645
  type ComposerHandle
@@ -563,6 +671,12 @@ LazyChat
563
671
  | `↓` | Recall next |
564
672
  | `Esc` | (host-bound) cancel stream |
565
673
 
674
+ > **Custom composers built on `MarkdownEditor`:** pass `onSubmit` to
675
+ > the editor for the same behaviour. A React `onKeyDown` wrapper does
676
+ > NOT reliably intercept Enter before Tiptap commits the HardBreak
677
+ > — see `MarkdownEditor/README.md#submit-on-enter` for the full
678
+ > incident write-up and the keymap-extension fix.
679
+
566
680
  ## Performance
567
681
 
568
682
  - **Token coalescing.** `createTokenBuffer` aggregates stream chunks within ~16ms before dispatching → ≤1 render per frame.
@@ -623,4 +737,5 @@ Full implementation plan and rationale lives at [`@dev/@refactoring7-chat/`](../
623
737
  - `WithPersonas` — config-level `user` + `assistant` identity (avatar, name)
624
738
  - `MultiUser` — per-message `sender` overrides (multi-user / multi-bot)
625
739
  - `WithHideComposer` — `hideComposer` + `footer` approval gate (HITL / agent-pause pattern)
740
+ - `WithRenderAfterCalls` — `renderAfterCalls` (vehicle cards outside panels), `hideToolCalls`, `renderToolCall` (custom per-call renderer) — knob-controlled
626
741
  - `Playground` — knobs (latency, streaming, suggestions)
@@ -24,6 +24,25 @@ export interface ToolCallsProps {
24
24
  renderStreaming?: (text: string, call: ChatToolCall) => ReactNode;
25
25
  /** Single override for all three; specific renderers above take precedence. */
26
26
  renderPayload?: (value: unknown, kind: ToolPayloadKind, call: ChatToolCall) => ReactNode;
27
+ /**
28
+ * Rendered once **after** all tool-call panels — always visible, outside
29
+ * the collapsible panels. Use for rich UI derived from tool outputs (e.g.
30
+ * vehicle cards, map pins, tax breakdowns). Receives the full calls array
31
+ * so the renderer can aggregate across multiple tool calls.
32
+ */
33
+ renderAfterCalls?: (calls: ChatToolCall[]) => ReactNode;
34
+ /**
35
+ * Custom renderer for each individual tool-call panel. When provided,
36
+ * replaces the default collapsible `<ToolCallItem>`. Return `null` to
37
+ * suppress a specific call while still letting others render.
38
+ */
39
+ renderToolCall?: (call: ChatToolCall) => ReactNode;
40
+ /**
41
+ * When `true`, the collapsible tool-call panels are not rendered at all.
42
+ * `renderAfterCalls` still runs — use together to show only rich UI with
43
+ * no raw accordion panels visible.
44
+ */
45
+ hideToolCalls?: boolean;
27
46
  className?: string;
28
47
  }
29
48
 
@@ -35,23 +54,31 @@ export function ToolCalls({
35
54
  renderOutput,
36
55
  renderStreaming,
37
56
  renderPayload,
57
+ renderAfterCalls,
58
+ renderToolCall,
59
+ hideToolCalls = false,
38
60
  className,
39
61
  }: ToolCallsProps) {
40
62
  if (!calls?.length) return null;
41
63
  return (
42
64
  <div className={cn('mt-2 space-y-1.5', className)}>
43
- {calls.map((call) => (
44
- <ToolCallItem
45
- key={call.id}
46
- call={call}
47
- defaultExpanded={defaultExpanded}
48
- expandWhileStreaming={expandWhileStreaming}
49
- renderInput={renderInput}
50
- renderOutput={renderOutput}
51
- renderStreaming={renderStreaming}
52
- renderPayload={renderPayload}
53
- />
54
- ))}
65
+ {!hideToolCalls && calls.map((call) =>
66
+ renderToolCall
67
+ ? <div key={call.id}>{renderToolCall(call)}</div>
68
+ : (
69
+ <ToolCallItem
70
+ key={call.id}
71
+ call={call}
72
+ defaultExpanded={defaultExpanded}
73
+ expandWhileStreaming={expandWhileStreaming}
74
+ renderInput={renderInput}
75
+ renderOutput={renderOutput}
76
+ renderStreaming={renderStreaming}
77
+ renderPayload={renderPayload}
78
+ />
79
+ ),
80
+ )}
81
+ {renderAfterCalls ? renderAfterCalls(calls) : null}
55
82
  </div>
56
83
  );
57
84
  }
@@ -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
- const trimmed = value.trim();
139
- if ((!trimmed && attachments.length === 0) || isSubmitting || disabled) return;
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 && trimmed) {
166
+ if (history.enabled !== false && cleaned) {
143
167
  const buf = historyRef.current.items;
144
- if (buf[buf.length - 1] !== trimmed) {
145
- buf.push(trimmed);
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(text, snapshot);
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 && (value.trim().length > 0 || attachments.length > 0);
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,
@@ -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.