@djangocfg/ui-tools 2.1.446 → 2.1.448
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/composer-registry/index.cjs.map +1 -1
- package/dist/composer-registry/index.d.cts +7 -0
- package/dist/composer-registry/index.d.ts +7 -0
- package/dist/composer-registry/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/chat/composer/Composer.tsx +58 -7
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +9 -0
- package/src/tools/chat/composer/useComposerAttach.tsx +14 -0
- package/src/tools/chat/composer/usePaneDrop.tsx +208 -0
- package/src/tools/chat/context/ChatProvider.tsx +12 -1
- package/src/tools/chat/hooks/__tests__/useChat.initialMessages.test.tsx +161 -0
- package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +7 -2
- package/src/tools/chat/hooks/useChat.ts +42 -2
- package/src/tools/chat/messages/Attachments.tsx +4 -0
- package/src/tools/chat/messages/MessageList.tsx +37 -1
- package/src/tools/chat/shell/ChatRoot.tsx +13 -2
- package/src/tools/integration/ComposerRegistry/index.ts +7 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +31 -3
- package/src/tools/media/ImageViewer/types.ts +9 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tools/integration/ComposerRegistry/index.ts"],"names":["__name","useCallback","useSyncExternalStore"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"sources":["../../src/tools/integration/ComposerRegistry/index.ts"],"names":["__name","useCallback","useSyncExternalStore"],"mappings":";;;;;AAoEA,IAAI,MAAA,GAAgC,IAAA;AACpC,IAAM,SAAA,uBAAgB,GAAA,EAAc;AAG7B,SAAS,iBAAiB,MAAA,EAAqC;AACpE,EAAA,MAAA,GAAS,MAAA;AACT,EAAA,KAAA,MAAW,EAAA,IAAM,SAAA,EAAW,EAAA,CAAG,MAAM,CAAA;AACvC;AAHgBA,wBAAA,CAAA,gBAAA,EAAA,kBAAA,CAAA;AAST,SAAS,eAAe,MAAA,EAAoC;AACjE,EAAA,gBAAA,CAAiB,MAAM,CAAA;AACvB,EAAA,OAAO,MAAM;AACX,IAAA,IAAI,MAAA,KAAW,MAAA,EAAQ,gBAAA,CAAiB,IAAI,CAAA;AAAA,EAC9C,CAAA;AACF;AALgBA,wBAAA,CAAA,cAAA,EAAA,gBAAA,CAAA;AAQT,SAAS,iBAAA,GAA2C;AACzD,EAAA,OAAO,MAAA;AACT;AAFgBA,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAKT,SAAS,kBAAkB,QAAA,EAAgC;AAChE,EAAA,SAAA,CAAU,IAAI,QAAQ,CAAA;AACtB,EAAA,OAAO,MAAM;AACX,IAAA,SAAA,CAAU,OAAO,QAAQ,CAAA;AAAA,EAC3B,CAAA;AACF;AALgBA,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAYT,SAAS,iBAAA,GAA2C;AACzD,EAAA,MAAM,SAAA,GAAYC,iBAAA,CAAY,CAAC,QAAA,KAAyB;AACtD,IAAA,OAAO,kBAAkB,QAAQ,CAAA;AAAA,EACnC,CAAA,EAAG,EAAE,CAAA;AACL,EAAA,OAAOC,0BAAA,CAAqB,SAAA,EAAW,iBAAA,EAAmB,MAAM,IAAI,CAAA;AACtE;AALgBF,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA","file":"index.cjs","sourcesContent":["'use client';\n\nimport { useCallback, useSyncExternalStore } from 'react';\n\n/**\n * Minimal imperative handle every text-editor surface implements so\n * an external tool (voice dictation, command palette, AI suggestion)\n * can read/write its text content without traversing React.\n *\n * Methods are optional so a host can register a partial handle\n * (e.g. only `getValue` + `setValue`), and the caller checks before use.\n */\nexport interface ComposerHandle {\n /** Move keyboard focus into the composer's editable surface. */\n focus: () => void;\n /** Move the caret to the very end of the input. */\n moveCursorToEnd?: () => void;\n /** Read the current draft text. Voice dictation anchors partial\n * transcripts onto the user's already-typed prefix via this. */\n getValue?: () => string;\n /** Replace the current draft text. Voice dictation pushes interim\n * and final transcripts through this without owning a controlled\n * binding. */\n setValue?: (value: string) => void;\n /** Attach already-resolved files through the composer's validated attach\n * pipeline (size / type / count checks + upload + staging tray) — the\n * same entry point the paperclip, paste and drag-drop use. Present only\n * when the composer mounts the attach pipeline. A native shell (WKWebView /\n * WebView2) that intercepts an OS file drop hands the bytes here so a\n * native drop behaves exactly like an in-page one. */\n attachFiles?: (files: File[]) => void;\n}\n\n/**\n * `@djangocfg/ui-tools/composer-registry`\n *\n * Cross-tool bridge: the currently-active text composer's handle.\n *\n * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):\n * register their composer's imperative handle via `attachComposer`.\n *\n * Consumer side (`@djangocfg/ui-tools/speech-recognition`):\n * reads the active handle via `useActiveComposer`/`getActiveComposer`\n * and pipes voice transcripts into it.\n *\n * Why this lives in its own subpath (not inside `chat`)\n * ----------------------------------------------------\n * `chat` and `speech-recognition` are sibling subpath exports. If the\n * registry lived inside `chat`, then `speech-recognition` would have\n * to reach into it via a cross-tool relative import — and under Vite\n * dev's dependency optimizer that file ends up loaded TWICE (once via\n * the `./chat` URL, once via the `./speech-recognition` relative-up\n * URL), giving the producer and the consumer two separate `let active`\n * slots. The active handle registered by chat would be invisible to\n * speech-recognition (and vice versa).\n *\n * Putting the registry in its own dedicated subpath (a single tool\n * that NEITHER chat nor speech-recognition cross-import — they both\n * import this one as their dependency) means Vite resolves it from a\n * single URL across the whole graph. One module instance, one shared\n * `active` slot.\n *\n * Semantics: one active composer per realm. The most recent\n * `registerComposer(handle)` wins; `registerComposer(null)` clears it.\n */\n\ntype Listener = (handle: ComposerHandle | null) => void;\n\nlet active: ComposerHandle | null = null;\nconst listeners = new Set<Listener>();\n\n/** Set or replace the active composer handle. Pass `null` to clear. */\nexport function registerComposer(handle: ComposerHandle | null): void {\n active = handle;\n for (const fn of listeners) fn(active);\n}\n\n/**\n * Convenience for components: register on mount, unregister on\n * unmount. Returns a cleanup function suitable for `useEffect`.\n */\nexport function attachComposer(handle: ComposerHandle): () => void {\n registerComposer(handle);\n return () => {\n if (active === handle) registerComposer(null);\n };\n}\n\n/** Read the current active handle (no subscription). */\nexport function getActiveComposer(): ComposerHandle | null {\n return active;\n}\n\n/** Subscribe to handle changes; returns an unsubscribe fn. */\nexport function subscribeComposer(listener: Listener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\n/**\n * React hook: re-renders the caller whenever the active composer\n * changes. Built on `useSyncExternalStore` so concurrent rendering,\n * SSR, and dev-mode strict-effects all behave correctly.\n */\nexport function useActiveComposer(): ComposerHandle | null {\n const subscribe = useCallback((onChange: () => void) => {\n return subscribeComposer(onChange);\n }, []);\n return useSyncExternalStore(subscribe, getActiveComposer, () => null);\n}\n"]}
|
|
@@ -18,6 +18,13 @@ interface ComposerHandle {
|
|
|
18
18
|
* and final transcripts through this without owning a controlled
|
|
19
19
|
* binding. */
|
|
20
20
|
setValue?: (value: string) => void;
|
|
21
|
+
/** Attach already-resolved files through the composer's validated attach
|
|
22
|
+
* pipeline (size / type / count checks + upload + staging tray) — the
|
|
23
|
+
* same entry point the paperclip, paste and drag-drop use. Present only
|
|
24
|
+
* when the composer mounts the attach pipeline. A native shell (WKWebView /
|
|
25
|
+
* WebView2) that intercepts an OS file drop hands the bytes here so a
|
|
26
|
+
* native drop behaves exactly like an in-page one. */
|
|
27
|
+
attachFiles?: (files: File[]) => void;
|
|
21
28
|
}
|
|
22
29
|
/**
|
|
23
30
|
* `@djangocfg/ui-tools/composer-registry`
|
|
@@ -18,6 +18,13 @@ interface ComposerHandle {
|
|
|
18
18
|
* and final transcripts through this without owning a controlled
|
|
19
19
|
* binding. */
|
|
20
20
|
setValue?: (value: string) => void;
|
|
21
|
+
/** Attach already-resolved files through the composer's validated attach
|
|
22
|
+
* pipeline (size / type / count checks + upload + staging tray) — the
|
|
23
|
+
* same entry point the paperclip, paste and drag-drop use. Present only
|
|
24
|
+
* when the composer mounts the attach pipeline. A native shell (WKWebView /
|
|
25
|
+
* WebView2) that intercepts an OS file drop hands the bytes here so a
|
|
26
|
+
* native drop behaves exactly like an in-page one. */
|
|
27
|
+
attachFiles?: (files: File[]) => void;
|
|
21
28
|
}
|
|
22
29
|
/**
|
|
23
30
|
* `@djangocfg/ui-tools/composer-registry`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tools/integration/ComposerRegistry/index.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"sources":["../../src/tools/integration/ComposerRegistry/index.ts"],"names":[],"mappings":";;;AAoEA,IAAI,MAAA,GAAgC,IAAA;AACpC,IAAM,SAAA,uBAAgB,GAAA,EAAc;AAG7B,SAAS,iBAAiB,MAAA,EAAqC;AACpE,EAAA,MAAA,GAAS,MAAA;AACT,EAAA,KAAA,MAAW,EAAA,IAAM,SAAA,EAAW,EAAA,CAAG,MAAM,CAAA;AACvC;AAHgB,MAAA,CAAA,gBAAA,EAAA,kBAAA,CAAA;AAST,SAAS,eAAe,MAAA,EAAoC;AACjE,EAAA,gBAAA,CAAiB,MAAM,CAAA;AACvB,EAAA,OAAO,MAAM;AACX,IAAA,IAAI,MAAA,KAAW,MAAA,EAAQ,gBAAA,CAAiB,IAAI,CAAA;AAAA,EAC9C,CAAA;AACF;AALgB,MAAA,CAAA,cAAA,EAAA,gBAAA,CAAA;AAQT,SAAS,iBAAA,GAA2C;AACzD,EAAA,OAAO,MAAA;AACT;AAFgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAKT,SAAS,kBAAkB,QAAA,EAAgC;AAChE,EAAA,SAAA,CAAU,IAAI,QAAQ,CAAA;AACtB,EAAA,OAAO,MAAM;AACX,IAAA,SAAA,CAAU,OAAO,QAAQ,CAAA;AAAA,EAC3B,CAAA;AACF;AALgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAYT,SAAS,iBAAA,GAA2C;AACzD,EAAA,MAAM,SAAA,GAAY,WAAA,CAAY,CAAC,QAAA,KAAyB;AACtD,IAAA,OAAO,kBAAkB,QAAQ,CAAA;AAAA,EACnC,CAAA,EAAG,EAAE,CAAA;AACL,EAAA,OAAO,oBAAA,CAAqB,SAAA,EAAW,iBAAA,EAAmB,MAAM,IAAI,CAAA;AACtE;AALgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA","file":"index.mjs","sourcesContent":["'use client';\n\nimport { useCallback, useSyncExternalStore } from 'react';\n\n/**\n * Minimal imperative handle every text-editor surface implements so\n * an external tool (voice dictation, command palette, AI suggestion)\n * can read/write its text content without traversing React.\n *\n * Methods are optional so a host can register a partial handle\n * (e.g. only `getValue` + `setValue`), and the caller checks before use.\n */\nexport interface ComposerHandle {\n /** Move keyboard focus into the composer's editable surface. */\n focus: () => void;\n /** Move the caret to the very end of the input. */\n moveCursorToEnd?: () => void;\n /** Read the current draft text. Voice dictation anchors partial\n * transcripts onto the user's already-typed prefix via this. */\n getValue?: () => string;\n /** Replace the current draft text. Voice dictation pushes interim\n * and final transcripts through this without owning a controlled\n * binding. */\n setValue?: (value: string) => void;\n /** Attach already-resolved files through the composer's validated attach\n * pipeline (size / type / count checks + upload + staging tray) — the\n * same entry point the paperclip, paste and drag-drop use. Present only\n * when the composer mounts the attach pipeline. A native shell (WKWebView /\n * WebView2) that intercepts an OS file drop hands the bytes here so a\n * native drop behaves exactly like an in-page one. */\n attachFiles?: (files: File[]) => void;\n}\n\n/**\n * `@djangocfg/ui-tools/composer-registry`\n *\n * Cross-tool bridge: the currently-active text composer's handle.\n *\n * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):\n * register their composer's imperative handle via `attachComposer`.\n *\n * Consumer side (`@djangocfg/ui-tools/speech-recognition`):\n * reads the active handle via `useActiveComposer`/`getActiveComposer`\n * and pipes voice transcripts into it.\n *\n * Why this lives in its own subpath (not inside `chat`)\n * ----------------------------------------------------\n * `chat` and `speech-recognition` are sibling subpath exports. If the\n * registry lived inside `chat`, then `speech-recognition` would have\n * to reach into it via a cross-tool relative import — and under Vite\n * dev's dependency optimizer that file ends up loaded TWICE (once via\n * the `./chat` URL, once via the `./speech-recognition` relative-up\n * URL), giving the producer and the consumer two separate `let active`\n * slots. The active handle registered by chat would be invisible to\n * speech-recognition (and vice versa).\n *\n * Putting the registry in its own dedicated subpath (a single tool\n * that NEITHER chat nor speech-recognition cross-import — they both\n * import this one as their dependency) means Vite resolves it from a\n * single URL across the whole graph. One module instance, one shared\n * `active` slot.\n *\n * Semantics: one active composer per realm. The most recent\n * `registerComposer(handle)` wins; `registerComposer(null)` clears it.\n */\n\ntype Listener = (handle: ComposerHandle | null) => void;\n\nlet active: ComposerHandle | null = null;\nconst listeners = new Set<Listener>();\n\n/** Set or replace the active composer handle. Pass `null` to clear. */\nexport function registerComposer(handle: ComposerHandle | null): void {\n active = handle;\n for (const fn of listeners) fn(active);\n}\n\n/**\n * Convenience for components: register on mount, unregister on\n * unmount. Returns a cleanup function suitable for `useEffect`.\n */\nexport function attachComposer(handle: ComposerHandle): () => void {\n registerComposer(handle);\n return () => {\n if (active === handle) registerComposer(null);\n };\n}\n\n/** Read the current active handle (no subscription). */\nexport function getActiveComposer(): ComposerHandle | null {\n return active;\n}\n\n/** Subscribe to handle changes; returns an unsubscribe fn. */\nexport function subscribeComposer(listener: Listener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\n/**\n * React hook: re-renders the caller whenever the active composer\n * changes. Built on `useSyncExternalStore` so concurrent rendering,\n * SSR, and dev-mode strict-effects all behave correctly.\n */\nexport function useActiveComposer(): ComposerHandle | null {\n const subscribe = useCallback((onChange: () => void) => {\n return subscribeComposer(onChange);\n }, []);\n return useSyncExternalStore(subscribe, getActiveComposer, () => null);\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.448",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -334,8 +334,8 @@
|
|
|
334
334
|
"test:watch": "vitest"
|
|
335
335
|
},
|
|
336
336
|
"peerDependencies": {
|
|
337
|
-
"@djangocfg/i18n": "^2.1.
|
|
338
|
-
"@djangocfg/ui-core": "^2.1.
|
|
337
|
+
"@djangocfg/i18n": "^2.1.448",
|
|
338
|
+
"@djangocfg/ui-core": "^2.1.448",
|
|
339
339
|
"consola": "^3.4.2",
|
|
340
340
|
"lodash-es": "^4.18.1",
|
|
341
341
|
"lucide-react": "^0.545.0",
|
|
@@ -418,9 +418,9 @@
|
|
|
418
418
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
419
419
|
},
|
|
420
420
|
"devDependencies": {
|
|
421
|
-
"@djangocfg/i18n": "^2.1.
|
|
422
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
423
|
-
"@djangocfg/ui-core": "^2.1.
|
|
421
|
+
"@djangocfg/i18n": "^2.1.448",
|
|
422
|
+
"@djangocfg/typescript-config": "^2.1.448",
|
|
423
|
+
"@djangocfg/ui-core": "^2.1.448",
|
|
424
424
|
"@types/lodash-es": "^4.17.12",
|
|
425
425
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
426
426
|
"@types/node": "^25.2.3",
|
|
@@ -19,6 +19,7 @@ import { SlashMenu } from './slash/SlashMenu';
|
|
|
19
19
|
import { useSlashCommands } from './slash/useSlashCommands';
|
|
20
20
|
import { useComposerActions } from './useComposerActions';
|
|
21
21
|
import { useComposerAttach } from './useComposerAttach';
|
|
22
|
+
import { usePaneDrop } from './usePaneDrop';
|
|
22
23
|
import type {
|
|
23
24
|
ComposerAppearance,
|
|
24
25
|
ComposerAttachConfig,
|
|
@@ -43,6 +44,21 @@ export interface ComposerProps {
|
|
|
43
44
|
/** Tune the built-in attach pipeline — accepted types, size / count
|
|
44
45
|
* caps, paste, and the optional upload transport. */
|
|
45
46
|
attach?: ComposerAttachConfig;
|
|
47
|
+
/**
|
|
48
|
+
* How large the file-drop target is.
|
|
49
|
+
*
|
|
50
|
+
* - `'composer'` (default) — only the composer surface accepts a drop
|
|
51
|
+
* (the historical behaviour).
|
|
52
|
+
* - `'pane'` — the WHOLE chat pane is a drop target: dragging a file
|
|
53
|
+
* anywhere over the chat shows a full-pane "Drop files here" overlay
|
|
54
|
+
* and drops route through the same validated `attachFiles` pipeline.
|
|
55
|
+
* Right when a pane hosts a single conversation (one chat open at a
|
|
56
|
+
* time) — a small composer-only target is easy to miss. The overlay
|
|
57
|
+
* covers the nearest positioned ancestor of the composer, so the host
|
|
58
|
+
* must give that ancestor `position: relative` (the chat pane usually
|
|
59
|
+
* already is). Falls back to the viewport if none is found.
|
|
60
|
+
*/
|
|
61
|
+
dropScope?: 'composer' | 'pane';
|
|
46
62
|
/** Override the built-in file picker. When set, the paperclip and the
|
|
47
63
|
* `+`-menu attach item call this instead of opening the native picker
|
|
48
64
|
* — for hosts that drive their own (e.g. a Wails native dialog). */
|
|
@@ -162,6 +178,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
162
178
|
disabled,
|
|
163
179
|
showAttachmentButton = false,
|
|
164
180
|
attach,
|
|
181
|
+
dropScope = 'composer',
|
|
165
182
|
onPickFiles,
|
|
166
183
|
className,
|
|
167
184
|
textareaClassName,
|
|
@@ -213,6 +230,13 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
213
230
|
// In that case the slot registers its own imperative handle; skip
|
|
214
231
|
// ours so we don't overwrite it (child effects run before parent).
|
|
215
232
|
const hasCustomTextarea = !!slots?.Textarea;
|
|
233
|
+
// The plain-textarea handle. `attachFiles` is threaded in via a ref (set
|
|
234
|
+
// once `attachHandle` exists, below) so the native OS-drop bridge
|
|
235
|
+
// (getActiveComposer().attachFiles) works for this path too, without a
|
|
236
|
+
// temporal-dead-zone on `attachHandle` (declared further down). The
|
|
237
|
+
// custom-textarea (TipTap) path carries attach the same way via
|
|
238
|
+
// ComposerRichTextarea reading the attach context.
|
|
239
|
+
const attachFilesRef = useRef<((files: File[]) => void) | undefined>(undefined);
|
|
216
240
|
useEffect(() => {
|
|
217
241
|
if (hasCustomTextarea) return;
|
|
218
242
|
return attachComposer({
|
|
@@ -225,6 +249,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
225
249
|
},
|
|
226
250
|
getValue: () => getValueRef.current(),
|
|
227
251
|
setValue: composerSetValue,
|
|
252
|
+
attachFiles: (files) => attachFilesRef.current?.(files),
|
|
228
253
|
});
|
|
229
254
|
}, [hasCustomTextarea, composerFocus, composerSetValue, textareaRef]);
|
|
230
255
|
|
|
@@ -338,6 +363,9 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
338
363
|
disabled: isDisabled,
|
|
339
364
|
pasteScopeRef: surfaceRef,
|
|
340
365
|
});
|
|
366
|
+
// Feed the plain-textarea registry handle its attach entry (see the
|
|
367
|
+
// attachComposer effect above — it reads through this ref to dodge a TDZ).
|
|
368
|
+
attachFilesRef.current = attachEnabled ? attachHandle.attachFiles : undefined;
|
|
341
369
|
// The resolved click handler: host override wins, else the built-in
|
|
342
370
|
// picker. `undefined` when attaching is not enabled at all.
|
|
343
371
|
const pickFiles = !attachEnabled
|
|
@@ -365,17 +393,38 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
365
393
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
366
394
|
if (dragDepth.current === 0) setIsDragging(false);
|
|
367
395
|
};
|
|
396
|
+
// When `dropScope==='pane'` the pane-level hook (usePaneDrop) owns the drop
|
|
397
|
+
// for the WHOLE pane, including the composer surface. Skip the composer's own
|
|
398
|
+
// surface drop then so a file landing on the composer isn't attached twice.
|
|
399
|
+
const paneScope = dropScope === 'pane' && attachEnabled && !isDisabled;
|
|
368
400
|
const handleDrop = (e: React.DragEvent) => {
|
|
369
|
-
if (!attachEnabled || isDisabled) return;
|
|
401
|
+
if (!attachEnabled || isDisabled || paneScope) return;
|
|
370
402
|
e.preventDefault();
|
|
371
403
|
dragDepth.current = 0;
|
|
372
404
|
setIsDragging(false);
|
|
373
405
|
const dropped = Array.from(e.dataTransfer.files ?? []);
|
|
374
|
-
// eslint-disable-next-line no-console
|
|
375
|
-
console.log('[composer] handleDrop files=%d names=%o', dropped.length, dropped.map((f) => f.name));
|
|
376
406
|
if (dropped.length > 0) attachHandle.attachFiles(dropped);
|
|
377
407
|
};
|
|
378
408
|
|
|
409
|
+
// Pane-wide drop target: dragging a file anywhere over the chat pane shows a
|
|
410
|
+
// full-pane overlay and routes the drop through the same validated pipeline.
|
|
411
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
412
|
+
const { paneOverlay } = usePaneDrop({
|
|
413
|
+
enabled: paneScope,
|
|
414
|
+
composerRef: rootRef,
|
|
415
|
+
onFiles: attachHandle.attachFiles,
|
|
416
|
+
});
|
|
417
|
+
// Merge the internal root ref (pane-drop needs the composer node to locate
|
|
418
|
+
// its pane) with the caller's forwarded ref.
|
|
419
|
+
const setRootRef = useCallback(
|
|
420
|
+
(node: HTMLDivElement | null) => {
|
|
421
|
+
rootRef.current = node;
|
|
422
|
+
if (typeof ref === 'function') ref(node);
|
|
423
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
424
|
+
},
|
|
425
|
+
[ref],
|
|
426
|
+
);
|
|
427
|
+
|
|
379
428
|
// Send, then return focus to the composer. Clicking the Send button
|
|
380
429
|
// moves focus to that button; after the message goes the caret should
|
|
381
430
|
// be back in the input so the user can keep typing without reaching for
|
|
@@ -618,7 +667,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
618
667
|
<ComposerSizeProvider value={size}>
|
|
619
668
|
<AttachProvider value={attachHandle}>
|
|
620
669
|
<div
|
|
621
|
-
ref={
|
|
670
|
+
ref={setRootRef}
|
|
622
671
|
{...dragProps}
|
|
623
672
|
onKeyDownCapture={handleSlashKeyDownCapture}
|
|
624
673
|
className={cn(
|
|
@@ -632,7 +681,8 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
632
681
|
)}
|
|
633
682
|
>
|
|
634
683
|
{hiddenFileInput}
|
|
635
|
-
{dragOverlay}
|
|
684
|
+
{!paneScope && dragOverlay}
|
|
685
|
+
{paneOverlay}
|
|
636
686
|
{slashOverlay}
|
|
637
687
|
{trayNode ? <div className="mb-1.5">{trayNode}</div> : null}
|
|
638
688
|
<div
|
|
@@ -743,7 +793,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
743
793
|
<ComposerSizeProvider value={size}>
|
|
744
794
|
<AttachProvider value={attachHandle}>
|
|
745
795
|
<div
|
|
746
|
-
ref={
|
|
796
|
+
ref={setRootRef}
|
|
747
797
|
{...dragProps}
|
|
748
798
|
onKeyDownCapture={handleSlashKeyDownCapture}
|
|
749
799
|
className={cn(
|
|
@@ -756,7 +806,8 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
756
806
|
)}
|
|
757
807
|
>
|
|
758
808
|
{hiddenFileInput}
|
|
759
|
-
{dragOverlay}
|
|
809
|
+
{!paneScope && dragOverlay}
|
|
810
|
+
{paneOverlay}
|
|
760
811
|
{slashOverlay}
|
|
761
812
|
{trayNode ? <div className="mb-2">{trayNode}</div> : null}
|
|
762
813
|
{surfaceWrapper}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type SlashCommandInfo,
|
|
10
10
|
} from '@djangocfg/ui-tools/markdown-editor';
|
|
11
11
|
|
|
12
|
+
import { useComposerAttachContext } from './AttachContext';
|
|
12
13
|
import { useRegisterComposer } from '../hooks/useAutoFocusOnStreamEnd';
|
|
13
14
|
import type { ComposerSize, ComposerTextareaProps } from './types';
|
|
14
15
|
|
|
@@ -95,11 +96,19 @@ export function ComposerRichTextarea({
|
|
|
95
96
|
[],
|
|
96
97
|
);
|
|
97
98
|
const getValue = useCallback(() => composer.value, [composer.value]);
|
|
99
|
+
// This TipTap slot OWNS the active composer handle (it wins over the
|
|
100
|
+
// plain-textarea registration in Composer.tsx for a custom textarea). So it
|
|
101
|
+
// must also carry `attachFiles`, else a native shell's OS-drop bridge
|
|
102
|
+
// (window.__cmdopFileDrop → getActiveComposer().attachFiles) finds no attach
|
|
103
|
+
// entry. Read it from the attach context the composer provides.
|
|
104
|
+
const attach = useComposerAttachContext();
|
|
105
|
+
const attachFiles = attach?.attachFiles;
|
|
98
106
|
useRegisterComposer({
|
|
99
107
|
focus,
|
|
100
108
|
moveCursorToEnd,
|
|
101
109
|
getValue,
|
|
102
110
|
setValue: composer.setValue,
|
|
111
|
+
attachFiles,
|
|
103
112
|
});
|
|
104
113
|
|
|
105
114
|
return (
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
|
|
4
4
|
|
|
5
|
+
import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry';
|
|
6
|
+
|
|
5
7
|
import { buildAcceptString, useClipboardPaste } from '../../forms/Uploader';
|
|
6
8
|
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
7
9
|
import { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
|
|
@@ -126,6 +128,7 @@ export function useComposerAttach({
|
|
|
126
128
|
const cap = maxFiles ?? Number.POSITIVE_INFINITY;
|
|
127
129
|
|
|
128
130
|
let slots = cap - c.attachments.length;
|
|
131
|
+
let staged = 0;
|
|
129
132
|
for (const file of files) {
|
|
130
133
|
if (slots <= 0) {
|
|
131
134
|
onRejectRef.current?.(file, 'count');
|
|
@@ -147,6 +150,17 @@ export function useComposerAttach({
|
|
|
147
150
|
runUpload(file, attachment.id, attachment.url);
|
|
148
151
|
}
|
|
149
152
|
slots -= 1;
|
|
153
|
+
staged += 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// After a successful attach (drop / paste / picker all land here), move
|
|
157
|
+
// the caret into the composer so the user can type the message
|
|
158
|
+
// immediately without reaching for the mouse — a dropped file is the
|
|
159
|
+
// start of composing, not the end. Route through the registry (not the
|
|
160
|
+
// plain-textarea `c.focus()`), so it also focuses the TipTap rich
|
|
161
|
+
// editor. rAF lets the staging tray commit first.
|
|
162
|
+
if (staged > 0) {
|
|
163
|
+
requestAnimationFrame(() => getActiveComposer()?.focus?.());
|
|
150
164
|
}
|
|
151
165
|
},
|
|
152
166
|
[disabled, maxFiles, maxSizeBytes, accept, runUpload],
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* usePaneDrop — widen the composer's file-drop target to the whole chat pane.
|
|
5
|
+
*
|
|
6
|
+
* The composer's own drag surface (`data-wails-dropzone` div) is a small
|
|
7
|
+
* target easy to miss. When a pane hosts one conversation at a time,
|
|
8
|
+
* dropping a file anywhere over the chat should attach it — the ChatGPT /
|
|
9
|
+
* Telegram / Slack model. This hook implements that WITHOUT the composer
|
|
10
|
+
* needing to own the pane's DOM:
|
|
11
|
+
*
|
|
12
|
+
* - It listens for drag events at the WINDOW level (capture) and gates on
|
|
13
|
+
* `dataTransfer.types.includes('Files')` — so a text/selection drag never
|
|
14
|
+
* triggers it.
|
|
15
|
+
* - The overlay is positioned over the composer's nearest POSITIONED
|
|
16
|
+
* ancestor (the chat pane, normally `position: relative`). If none is
|
|
17
|
+
* found it falls back to the viewport. A ResizeObserver + scroll/resize
|
|
18
|
+
* listeners keep the rect fresh while a drag is in flight.
|
|
19
|
+
* - The drop routes through the SAME `onFiles` (the composer's validated
|
|
20
|
+
* `attachFiles`) as the paperclip / paste / composer-drop paths — no
|
|
21
|
+
* second attach lane.
|
|
22
|
+
*
|
|
23
|
+
* Enabled only when `dropScope === 'pane'` and attaching is on; otherwise the
|
|
24
|
+
* hook is inert and returns a null overlay (the composer keeps its own
|
|
25
|
+
* surface-scoped drop).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
29
|
+
import { createPortal } from 'react-dom';
|
|
30
|
+
|
|
31
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
32
|
+
|
|
33
|
+
export interface UsePaneDropOptions {
|
|
34
|
+
/** Master switch — only bind when the host asked for pane scope + attach on. */
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
/** The composer root — used to find the pane (nearest positioned ancestor). */
|
|
37
|
+
composerRef: React.RefObject<HTMLElement | null>;
|
|
38
|
+
/** Funnel dropped files through the composer's validated attach pipeline. */
|
|
39
|
+
onFiles: (files: File[]) => void;
|
|
40
|
+
/** Overlay caption. Defaults to a plain English string. */
|
|
41
|
+
label?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UsePaneDropReturn {
|
|
45
|
+
/** Portal overlay node (or null). Render it anywhere — it portals to body. */
|
|
46
|
+
paneOverlay: React.ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Walk up to the nearest ancestor establishing a positioning context. */
|
|
50
|
+
function nearestPositionedAncestor(el: HTMLElement | null): HTMLElement | null {
|
|
51
|
+
let node = el?.parentElement ?? null;
|
|
52
|
+
while (node) {
|
|
53
|
+
const pos = getComputedStyle(node).position;
|
|
54
|
+
if (pos === 'relative' || pos === 'absolute' || pos === 'fixed' || pos === 'sticky') {
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
node = node.parentElement;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function usePaneDrop(options: UsePaneDropOptions): UsePaneDropReturn {
|
|
63
|
+
const { enabled, composerRef, onFiles, label = 'Drop files here' } = options;
|
|
64
|
+
|
|
65
|
+
const [dragging, setDragging] = useState(false);
|
|
66
|
+
const [rect, setRect] = useState<DOMRect | null>(null);
|
|
67
|
+
// Counter of enter/leave — a single boolean flickers as the cursor crosses
|
|
68
|
+
// child element boundaries (each generates leave→enter). Depth hits 0 only
|
|
69
|
+
// when the pointer truly left the window.
|
|
70
|
+
const depth = useRef(0);
|
|
71
|
+
const paneRef = useRef<HTMLElement | null>(null);
|
|
72
|
+
|
|
73
|
+
const measure = useCallback(() => {
|
|
74
|
+
const pane = paneRef.current;
|
|
75
|
+
setRect(pane ? pane.getBoundingClientRect() : null);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!enabled) {
|
|
80
|
+
// Ensure a scope flip mid-drag can't leave a stuck overlay.
|
|
81
|
+
depth.current = 0;
|
|
82
|
+
setDragging(false);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const hasFiles = (e: DragEvent) =>
|
|
87
|
+
Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
|
88
|
+
|
|
89
|
+
const onDragEnter = (e: DragEvent) => {
|
|
90
|
+
if (!hasFiles(e)) return;
|
|
91
|
+
// Resolve the pane lazily on the first enter (the composer is mounted by now).
|
|
92
|
+
paneRef.current = nearestPositionedAncestor(composerRef.current);
|
|
93
|
+
depth.current += 1;
|
|
94
|
+
if (!dragging) {
|
|
95
|
+
measure();
|
|
96
|
+
setDragging(true);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const onDragOver = (e: DragEvent) => {
|
|
100
|
+
if (!hasFiles(e)) return;
|
|
101
|
+
// Allow the drop (else the browser rejects it and onDrop never fires).
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
104
|
+
};
|
|
105
|
+
const onDragLeave = (e: DragEvent) => {
|
|
106
|
+
if (!hasFiles(e)) return;
|
|
107
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
108
|
+
if (depth.current === 0) setDragging(false);
|
|
109
|
+
};
|
|
110
|
+
const onDrop = (e: DragEvent) => {
|
|
111
|
+
if (!hasFiles(e)) return;
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
depth.current = 0;
|
|
114
|
+
setDragging(false);
|
|
115
|
+
const files = Array.from(e.dataTransfer?.files ?? []);
|
|
116
|
+
if (files.length > 0) onFiles(files);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Native-shell drag signal. WKWebView / WebView2 intercept an OS file drag
|
|
120
|
+
// natively and NEVER emit DOM dragenter/leave, so the listeners above can't
|
|
121
|
+
// light the overlay. The native side instead fans out a `cmdop:native-drag`
|
|
122
|
+
// window event (see cmdop transport `native-drop.ts`) as the drag enters /
|
|
123
|
+
// leaves; we drive the overlay off that. The actual files still arrive via
|
|
124
|
+
// the native `window.__cmdopFileDrop` → attachFiles path, not a DOM drop.
|
|
125
|
+
const onNativeDrag = (e: Event) => {
|
|
126
|
+
const active = !!(e as CustomEvent<{ active?: boolean }>).detail?.active;
|
|
127
|
+
if (active) {
|
|
128
|
+
paneRef.current = nearestPositionedAncestor(composerRef.current);
|
|
129
|
+
measure();
|
|
130
|
+
setDragging(true);
|
|
131
|
+
} else {
|
|
132
|
+
depth.current = 0;
|
|
133
|
+
setDragging(false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Capture phase so we see the drag before any inner handler stops it.
|
|
138
|
+
window.addEventListener('dragenter', onDragEnter, true);
|
|
139
|
+
window.addEventListener('dragover', onDragOver, true);
|
|
140
|
+
window.addEventListener('dragleave', onDragLeave, true);
|
|
141
|
+
window.addEventListener('drop', onDrop, true);
|
|
142
|
+
window.addEventListener('cmdop:native-drag', onNativeDrag);
|
|
143
|
+
return () => {
|
|
144
|
+
window.removeEventListener('dragenter', onDragEnter, true);
|
|
145
|
+
window.removeEventListener('dragover', onDragOver, true);
|
|
146
|
+
window.removeEventListener('dragleave', onDragLeave, true);
|
|
147
|
+
window.removeEventListener('drop', onDrop, true);
|
|
148
|
+
window.removeEventListener('cmdop:native-drag', onNativeDrag);
|
|
149
|
+
};
|
|
150
|
+
}, [enabled, composerRef, onFiles, measure, dragging]);
|
|
151
|
+
|
|
152
|
+
// Keep the overlay aligned with the pane while a drag is in flight.
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!dragging) return;
|
|
155
|
+
const onChange = () => measure();
|
|
156
|
+
window.addEventListener('scroll', onChange, true);
|
|
157
|
+
window.addEventListener('resize', onChange);
|
|
158
|
+
const ro = paneRef.current ? new ResizeObserver(onChange) : null;
|
|
159
|
+
if (ro && paneRef.current) ro.observe(paneRef.current);
|
|
160
|
+
return () => {
|
|
161
|
+
window.removeEventListener('scroll', onChange, true);
|
|
162
|
+
window.removeEventListener('resize', onChange);
|
|
163
|
+
ro?.disconnect();
|
|
164
|
+
};
|
|
165
|
+
}, [dragging, measure]);
|
|
166
|
+
|
|
167
|
+
if (!enabled || !dragging || typeof document === 'undefined') {
|
|
168
|
+
return { paneOverlay: null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Cover the pane rect (or the viewport if we couldn't find a positioned
|
|
172
|
+
// ancestor). `pointer-events-none` so the underlying drop target still
|
|
173
|
+
// receives the drop event — the overlay is a pure visual affordance.
|
|
174
|
+
const style: React.CSSProperties = rect
|
|
175
|
+
? { position: 'fixed', top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
|
176
|
+
: { position: 'fixed', inset: 0 };
|
|
177
|
+
|
|
178
|
+
const overlay = (
|
|
179
|
+
<div
|
|
180
|
+
aria-hidden
|
|
181
|
+
className="pointer-events-none z-50"
|
|
182
|
+
style={style}
|
|
183
|
+
>
|
|
184
|
+
<div
|
|
185
|
+
className={cn(
|
|
186
|
+
'absolute inset-2 flex flex-col items-center justify-center gap-3 rounded-2xl',
|
|
187
|
+
'border-2 border-dashed border-primary/50 bg-primary/5 backdrop-blur-[2px]',
|
|
188
|
+
'transition-opacity duration-150',
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 ring-1 ring-primary/30">
|
|
192
|
+
<svg
|
|
193
|
+
width="26" height="26" viewBox="0 0 24 24" fill="none"
|
|
194
|
+
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
|
|
195
|
+
className="text-primary"
|
|
196
|
+
>
|
|
197
|
+
<path d="M12 3v12" />
|
|
198
|
+
<path d="m7 10 5 5 5-5" />
|
|
199
|
+
<path d="M5 21h14" />
|
|
200
|
+
</svg>
|
|
201
|
+
</div>
|
|
202
|
+
<span className="text-sm font-medium text-foreground/90">{label}</span>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return { paneOverlay: createPortal(overlay, document.body) };
|
|
208
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry';
|
|
14
14
|
|
|
15
|
-
import type { ChatConfig, ChatLabels, ChatTransport } from '../types';
|
|
15
|
+
import type { ChatConfig, ChatLabels, ChatMessage, ChatTransport } from '../types';
|
|
16
16
|
import { DEFAULT_LABELS } from '../types';
|
|
17
17
|
import type { BlockRegistry } from '../messages/blocks';
|
|
18
18
|
|
|
@@ -63,6 +63,15 @@ export interface ChatProviderProps {
|
|
|
63
63
|
transport: ChatTransport;
|
|
64
64
|
config?: ChatConfig;
|
|
65
65
|
initialSessionId?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Warm transcript rendered instantly on mount while the resume-path
|
|
68
|
+
* `loadHistory` revalidates in the background (see `useChat`). Pair with
|
|
69
|
+
* `initialSessionId`: the host caches the last-seen transcript per room and
|
|
70
|
+
* passes it here so returning to a conversation shows content immediately
|
|
71
|
+
* instead of a cold refetch Spinner. The server read remains the source of
|
|
72
|
+
* truth — this is a visual head-start, replaced by the loaded page.
|
|
73
|
+
*/
|
|
74
|
+
initialMessages?: readonly ChatMessage[];
|
|
66
75
|
autoCreateSession?: boolean;
|
|
67
76
|
streaming?: boolean;
|
|
68
77
|
/** Audio-trigger configuration. Off by default (no `sounds` map). */
|
|
@@ -110,6 +119,7 @@ export function ChatProvider({
|
|
|
110
119
|
transport,
|
|
111
120
|
config = {},
|
|
112
121
|
initialSessionId,
|
|
122
|
+
initialMessages,
|
|
113
123
|
autoCreateSession,
|
|
114
124
|
streaming,
|
|
115
125
|
audio,
|
|
@@ -136,6 +146,7 @@ export function ChatProvider({
|
|
|
136
146
|
const chat = useChat({
|
|
137
147
|
transport,
|
|
138
148
|
initialSessionId,
|
|
149
|
+
initialMessages,
|
|
139
150
|
autoCreateSession,
|
|
140
151
|
streaming,
|
|
141
152
|
debug,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// F5 Part 2a — the `initialMessages` warm-hydrate seam.
|
|
4
|
+
//
|
|
5
|
+
// Contract under test (see useChat bootstrap):
|
|
6
|
+
// 1. With `initialSessionId` + non-empty `initialMessages`, the engine renders
|
|
7
|
+
// the seed synchronously on first commit — `historyLoaded` is already true,
|
|
8
|
+
// so the host never paints a Spinner (which gates on `!historyLoaded`).
|
|
9
|
+
// 2. The resume-path `loadHistory` STILL runs and REPLACES the seed with the
|
|
10
|
+
// server's authoritative page (server = source of truth; no stale-read).
|
|
11
|
+
// 3. Empty `initialMessages` (or the create path) is a no-op — normal
|
|
12
|
+
// loading behaviour, seed ignored.
|
|
13
|
+
import { act, createElement } from 'react';
|
|
14
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
// React needs this global to accept act(...) outside a test-runner integration.
|
|
18
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
|
+
|
|
20
|
+
import { useChat, type UseChatReturn } from '../useChat';
|
|
21
|
+
import type { ChatMessage } from '../../types';
|
|
22
|
+
import type { ChatTransport } from '../../types/transport';
|
|
23
|
+
import type { HistoryPage, SessionInfo } from '../../types/session';
|
|
24
|
+
|
|
25
|
+
function msg(id: string, content: string, role: ChatMessage['role'] = 'user'): ChatMessage {
|
|
26
|
+
return { id, role, content, createdAt: 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Transport whose loadHistory resolves only when the test releases it — lets us
|
|
30
|
+
* observe the synchronous-seed render BEFORE the server page lands. */
|
|
31
|
+
function deferredTransport(page: HistoryPage): {
|
|
32
|
+
transport: ChatTransport;
|
|
33
|
+
resolveHistory: () => void;
|
|
34
|
+
historyCalls: () => number;
|
|
35
|
+
} {
|
|
36
|
+
let release!: () => void;
|
|
37
|
+
const gate = new Promise<void>((r) => {
|
|
38
|
+
release = r;
|
|
39
|
+
});
|
|
40
|
+
let calls = 0;
|
|
41
|
+
const transport: ChatTransport = {
|
|
42
|
+
async createSession(): Promise<SessionInfo> {
|
|
43
|
+
return { sessionId: 'new-session' };
|
|
44
|
+
},
|
|
45
|
+
async loadHistory(): Promise<HistoryPage> {
|
|
46
|
+
calls += 1;
|
|
47
|
+
await gate;
|
|
48
|
+
return page;
|
|
49
|
+
},
|
|
50
|
+
async *stream() {
|
|
51
|
+
/* unused */
|
|
52
|
+
},
|
|
53
|
+
async send(): Promise<ChatMessage> {
|
|
54
|
+
return msg('x', 'x', 'assistant');
|
|
55
|
+
},
|
|
56
|
+
async closeSession() {
|
|
57
|
+
/* unused */
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { transport, resolveHistory: release, historyCalls: () => calls };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('useChat — initialMessages warm-hydrate seam', () => {
|
|
64
|
+
let roots: Root[] = [];
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
roots = [];
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
act(() => roots.forEach((r) => r.unmount()));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders the seed synchronously (historyLoaded true, seed messages) on the resume path', async () => {
|
|
73
|
+
const seed = [msg('s1', 'cached hello'), msg('s2', 'cached reply', 'assistant')];
|
|
74
|
+
const { transport, historyCalls } = deferredTransport({
|
|
75
|
+
messages: [msg('h1', 'server hello')],
|
|
76
|
+
hasMore: false,
|
|
77
|
+
nextCursor: null,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const snaps: UseChatReturn[] = [];
|
|
81
|
+
const root = createRoot(document.createElement('div'));
|
|
82
|
+
roots.push(root);
|
|
83
|
+
await act(async () => {
|
|
84
|
+
root.render(createElement(function W() {
|
|
85
|
+
const chat = useChat({ transport, initialSessionId: 'room-1', initialMessages: seed });
|
|
86
|
+
snaps.push(chat);
|
|
87
|
+
return null;
|
|
88
|
+
}));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// The seed was committed and the load settled flag is true → no Spinner.
|
|
92
|
+
const seededSnap = snaps.find((s) => s.messages.length === 2 && s.historyLoaded);
|
|
93
|
+
expect(seededSnap, 'a snapshot with the 2 seed messages + historyLoaded=true').toBeDefined();
|
|
94
|
+
expect(seededSnap!.messages.map((m) => m.content)).toEqual(['cached hello', 'cached reply']);
|
|
95
|
+
expect(seededSnap!.sessionId).toBe('room-1');
|
|
96
|
+
// loadHistory was invoked (background revalidation is in flight, still gated).
|
|
97
|
+
expect(historyCalls()).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('replaces the seed with the server page once loadHistory resolves', async () => {
|
|
101
|
+
const seed = [msg('s1', 'STALE cached')];
|
|
102
|
+
const serverPage: HistoryPage = {
|
|
103
|
+
messages: [msg('h1', 'fresh server a'), msg('h2', 'fresh server b', 'assistant')],
|
|
104
|
+
hasMore: false,
|
|
105
|
+
nextCursor: null,
|
|
106
|
+
};
|
|
107
|
+
const { transport, resolveHistory } = deferredTransport(serverPage);
|
|
108
|
+
|
|
109
|
+
const snaps: UseChatReturn[] = [];
|
|
110
|
+
const root = createRoot(document.createElement('div'));
|
|
111
|
+
roots.push(root);
|
|
112
|
+
await act(async () => {
|
|
113
|
+
root.render(createElement(function W() {
|
|
114
|
+
const chat = useChat({ transport, initialSessionId: 'room-1', initialMessages: seed });
|
|
115
|
+
snaps.push(chat);
|
|
116
|
+
return null;
|
|
117
|
+
}));
|
|
118
|
+
});
|
|
119
|
+
// Release the background load and let the reducer commit HISTORY_LOAD_DONE.
|
|
120
|
+
await act(async () => {
|
|
121
|
+
resolveHistory();
|
|
122
|
+
await Promise.resolve();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const last = snaps[snaps.length - 1];
|
|
126
|
+
expect(last.messages.map((m) => m.content)).toEqual(['fresh server a', 'fresh server b']);
|
|
127
|
+
expect(last.messages.some((m) => m.content === 'STALE cached')).toBe(false);
|
|
128
|
+
expect(last.historyLoaded).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('ignores an empty initialMessages array (normal load path)', async () => {
|
|
132
|
+
const loadHistory = vi.fn(async (): Promise<HistoryPage> => ({
|
|
133
|
+
messages: [msg('h1', 'server only')],
|
|
134
|
+
hasMore: false,
|
|
135
|
+
nextCursor: null,
|
|
136
|
+
}));
|
|
137
|
+
const transport = {
|
|
138
|
+
createSession: async () => ({ sessionId: 'x' }),
|
|
139
|
+
loadHistory,
|
|
140
|
+
async *stream() {},
|
|
141
|
+
send: async () => msg('a', 'a', 'assistant'),
|
|
142
|
+
closeSession: async () => {},
|
|
143
|
+
} as unknown as ChatTransport;
|
|
144
|
+
|
|
145
|
+
const snaps: UseChatReturn[] = [];
|
|
146
|
+
const root = createRoot(document.createElement('div'));
|
|
147
|
+
roots.push(root);
|
|
148
|
+
await act(async () => {
|
|
149
|
+
root.render(createElement(function W() {
|
|
150
|
+
const chat = useChat({ transport, initialSessionId: 'room-1', initialMessages: [] });
|
|
151
|
+
snaps.push(chat);
|
|
152
|
+
return null;
|
|
153
|
+
}));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Empty seed: the load still drives the transcript, no seed shortcut.
|
|
157
|
+
const last = snaps[snaps.length - 1];
|
|
158
|
+
expect(last.messages.map((m) => m.content)).toEqual(['server only']);
|
|
159
|
+
expect(loadHistory).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -171,7 +171,12 @@ export function useRegisterComposer(handle: ComposerHandle): void {
|
|
|
171
171
|
// custom composers (e.g. the TipTap MarkdownEditor wrapper).
|
|
172
172
|
const getValue = handle.getValue;
|
|
173
173
|
const setValue = handle.setValue;
|
|
174
|
+
// Forward `attachFiles` too — the native OS-drop bridge routes an
|
|
175
|
+
// intercepted file drop through `getActiveComposer().attachFiles`, so a
|
|
176
|
+
// custom composer (TipTap MarkdownEditor wrapper) that owns the active
|
|
177
|
+
// handle must carry it or native drops silently no-op.
|
|
178
|
+
const attachFiles = handle.attachFiles;
|
|
174
179
|
useEffect(() => {
|
|
175
|
-
return attachComposer({ focus, moveCursorToEnd, getValue, setValue });
|
|
176
|
-
}, [focus, moveCursorToEnd, getValue, setValue]);
|
|
180
|
+
return attachComposer({ focus, moveCursorToEnd, getValue, setValue, attachFiles });
|
|
181
|
+
}, [focus, moveCursorToEnd, getValue, setValue, attachFiles]);
|
|
177
182
|
}
|
|
@@ -25,6 +25,21 @@ import { resolveSendMetadata } from '../core/metadata';
|
|
|
25
25
|
export interface UseChatConfig {
|
|
26
26
|
transport: ChatTransport;
|
|
27
27
|
initialSessionId?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Warm transcript to render synchronously on mount, before the resume-path
|
|
30
|
+
* `loadHistory` round-trip resolves. When set alongside `initialSessionId`,
|
|
31
|
+
* the engine seeds these messages and flips `historyLoaded` true immediately
|
|
32
|
+
* — no blocking Spinner — then still runs `loadHistory` in the background and
|
|
33
|
+
* REPLACES the seed with the server's authoritative page (via the normal
|
|
34
|
+
* `HISTORY_LOAD_DONE`). This makes "hydrate instant, revalidate in background"
|
|
35
|
+
* a first-class engine capability: the host caches the last-seen transcript
|
|
36
|
+
* (e.g. a per-room store) and passes it here so returning to a conversation
|
|
37
|
+
* shows content immediately instead of a cold refetch. The server read stays
|
|
38
|
+
* the source of truth — the seed is a visual head-start, never the last word.
|
|
39
|
+
*
|
|
40
|
+
* Ignored on the create path (no `initialSessionId`) and when empty.
|
|
41
|
+
*/
|
|
42
|
+
initialMessages?: readonly ChatMessage[];
|
|
28
43
|
autoCreateSession?: boolean;
|
|
29
44
|
streaming?: boolean;
|
|
30
45
|
pageSize?: number;
|
|
@@ -128,9 +143,34 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
128
143
|
return;
|
|
129
144
|
}
|
|
130
145
|
|
|
146
|
+
// Warm-hydrate: on the resume path, if the host handed us a cached
|
|
147
|
+
// transcript, commit it synchronously so the very first paint shows content
|
|
148
|
+
// (historyLoaded flips true via SESSION_SET) — no blocking Spinner. The
|
|
149
|
+
// resume-path `loadHistory` below still runs and REPLACES this seed with the
|
|
150
|
+
// server's authoritative page (HISTORY_LOAD_DONE), so the server stays the
|
|
151
|
+
// source of truth; the seed is only a visual head-start.
|
|
152
|
+
const warmSeed =
|
|
153
|
+
config.initialSessionId &&
|
|
154
|
+
config.initialMessages !== undefined &&
|
|
155
|
+
config.initialMessages.length > 0
|
|
156
|
+
? config.initialMessages
|
|
157
|
+
: null;
|
|
158
|
+
if (warmSeed) {
|
|
159
|
+
dispatch({
|
|
160
|
+
type: 'SESSION_SET',
|
|
161
|
+
sessionId: config.initialSessionId!,
|
|
162
|
+
// Copy to a mutable array — the reducer stores it as owned state and the
|
|
163
|
+
// caller's array may be a frozen cache entry.
|
|
164
|
+
messages: [...warmSeed],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
131
168
|
// Show "loading" state immediately so the UI doesn't look idle while we
|
|
132
|
-
// wait for createSession / loadHistory to come back.
|
|
133
|
-
|
|
169
|
+
// wait for createSession / loadHistory to come back. Skipped when we just
|
|
170
|
+
// warm-seeded — HISTORY_LOAD_START would set isLoading without clearing the
|
|
171
|
+
// seed, and the UI already has content to show; the background loadHistory
|
|
172
|
+
// reconciles without a spinner.
|
|
173
|
+
if (!warmSeed && (config.initialSessionId || autoCreateSession)) {
|
|
134
174
|
dispatch({ type: 'HISTORY_LOAD_START' });
|
|
135
175
|
}
|
|
136
176
|
|
|
@@ -130,6 +130,10 @@ function ImageLightboxProvider({
|
|
|
130
130
|
initialIndex={index}
|
|
131
131
|
lightbox
|
|
132
132
|
autoFocus
|
|
133
|
+
// Open at 80% of the viewport so the image sits with a comfortable
|
|
134
|
+
// margin (Preview / Quick Look) instead of slamming edge-to-edge;
|
|
135
|
+
// zoom/pan still reach full size from there.
|
|
136
|
+
fitScale={0.8}
|
|
133
137
|
onRequestClose={() => setOpen(false)}
|
|
134
138
|
/>
|
|
135
139
|
)}
|
|
@@ -140,7 +140,26 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
140
140
|
// ResizeObserver (to correct virtuoso's `followOutput:'auto'`, which
|
|
141
141
|
// settles a few px short on the final streamed chunk) and any other
|
|
142
142
|
// "land exactly at end" path.
|
|
143
|
+
//
|
|
144
|
+
// The INSTANT pin (smooth=false) — which the ResizeObserver fires on every
|
|
145
|
+
// last-bubble size change and on the send transition — writes `scrollTop`
|
|
146
|
+
// DIRECTLY rather than going through `scrollToIndex`. This is the same
|
|
147
|
+
// technique the initial-landing phase uses (see the land loop below) and for
|
|
148
|
+
// the same reason: `scrollToIndex` re-enters virtuoso's initial-location
|
|
149
|
+
// SEEK, which on a list whose new last item is still being measured (exactly
|
|
150
|
+
// the send moment, when the empty assistant placeholder just mounted) can
|
|
151
|
+
// momentarily reflow/blank rows — visible as a twitch. A direct scrollTop
|
|
152
|
+
// write is an ordinary scroll to virtuoso, never a seek, so it lands cleanly.
|
|
153
|
+
// The smooth path (user tapped "jump to latest") keeps `scrollToIndex` — a
|
|
154
|
+
// seek is fine there because it's a deliberate one-shot, not a per-frame pin.
|
|
143
155
|
const pinToBottom = useCallback((smooth = false) => {
|
|
156
|
+
if (!smooth) {
|
|
157
|
+
const el = scrollerRef.current;
|
|
158
|
+
if (el != null && el !== window && el instanceof HTMLElement) {
|
|
159
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
144
163
|
virtuosoRef.current?.scrollToIndex({
|
|
145
164
|
index: 'LAST',
|
|
146
165
|
align: 'end',
|
|
@@ -277,9 +296,26 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
277
296
|
// otherwise the call lands on the previous height and clips the new
|
|
278
297
|
// message under the composer. The initial-mount effect above handles
|
|
279
298
|
// first paint, so we skip until it has run.
|
|
299
|
+
//
|
|
300
|
+
// JITTER on send (live incident 2026-07-05: "чат дёргается при отправке"):
|
|
301
|
+
// a send appends TWO items a tick apart — the user bubble, then the empty
|
|
302
|
+
// assistant placeholder (STREAM_START). When the user is already at the
|
|
303
|
+
// bottom (the common case — they just sent), the last-item ResizeObserver's
|
|
304
|
+
// `pinToBottom` INSTANTLY lands them on the placeholder as it mounts/grows.
|
|
305
|
+
// A SECOND, `smooth` `scrollToIndex('LAST')` fired from here then animates
|
|
306
|
+
// into that instant pin — the smooth glide gets yanked mid-flight and reads
|
|
307
|
+
// as a twitch. Two corrections:
|
|
308
|
+
// 1. When the user is following (`isFollowingRef`), the sticky corrector
|
|
309
|
+
// already owns the landing — DON'T also fire the anchor scroll. It is
|
|
310
|
+
// only needed to pull a SCROLLED-UP user back down on their own send.
|
|
311
|
+
// 2. Use `behavior:'auto'` (instant), not `'smooth'`: a send should snap,
|
|
312
|
+
// and an instant scroll can't be visibly interrupted by the pin.
|
|
280
313
|
useEffect(() => {
|
|
281
314
|
if (scrollAnchorId == null) return;
|
|
282
315
|
if (!didInitialScrollRef.current) return;
|
|
316
|
+
// At-bottom users are landed by the sticky corrector; re-issuing a scroll
|
|
317
|
+
// here only collides with it. Skip — the pin has it covered.
|
|
318
|
+
if (isFollowingRef.current) return;
|
|
283
319
|
let raf1 = 0;
|
|
284
320
|
let raf2 = 0;
|
|
285
321
|
raf1 = requestAnimationFrame(() => {
|
|
@@ -288,7 +324,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
288
324
|
index: 'LAST',
|
|
289
325
|
align: 'end',
|
|
290
326
|
offset: BOTTOM_GAP_PX,
|
|
291
|
-
behavior: '
|
|
327
|
+
behavior: 'auto',
|
|
292
328
|
});
|
|
293
329
|
});
|
|
294
330
|
});
|
|
@@ -136,8 +136,18 @@ export interface ChatRootProps {
|
|
|
136
136
|
*/
|
|
137
137
|
transport?: ChatTransport;
|
|
138
138
|
config?: ChatConfig;
|
|
139
|
-
/**
|
|
140
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Session wiring — pre-existing id to attach, auto-create toggle, and an
|
|
141
|
+
* optional warm transcript. `initialMessages` (with `initialId`) renders the
|
|
142
|
+
* cached conversation instantly on mount while the server history revalidates
|
|
143
|
+
* in the background — see `ChatProvider` / `useChat`. The host owns the cache
|
|
144
|
+
* (e.g. a per-room store); the server load stays the source of truth.
|
|
145
|
+
*/
|
|
146
|
+
session?: {
|
|
147
|
+
initialId?: string;
|
|
148
|
+
autoCreate?: boolean;
|
|
149
|
+
initialMessages?: readonly ChatMessage[];
|
|
150
|
+
};
|
|
141
151
|
streaming?: boolean;
|
|
142
152
|
/** Audio-trigger configuration. Off by default (no `sounds` map). */
|
|
143
153
|
audio?: ChatAudioConfig;
|
|
@@ -210,6 +220,7 @@ export function ChatRoot(props: ChatRootProps) {
|
|
|
210
220
|
transport={transport}
|
|
211
221
|
config={config}
|
|
212
222
|
initialSessionId={session?.initialId}
|
|
223
|
+
initialMessages={session?.initialMessages}
|
|
213
224
|
autoCreateSession={session?.autoCreate}
|
|
214
225
|
streaming={streaming}
|
|
215
226
|
audio={audio}
|
|
@@ -22,6 +22,13 @@ export interface ComposerHandle {
|
|
|
22
22
|
* and final transcripts through this without owning a controlled
|
|
23
23
|
* binding. */
|
|
24
24
|
setValue?: (value: string) => void;
|
|
25
|
+
/** Attach already-resolved files through the composer's validated attach
|
|
26
|
+
* pipeline (size / type / count checks + upload + staging tray) — the
|
|
27
|
+
* same entry point the paperclip, paste and drag-drop use. Present only
|
|
28
|
+
* when the composer mounts the attach pipeline. A native shell (WKWebView /
|
|
29
|
+
* WebView2) that intercepts an OS file drop hands the bytes here so a
|
|
30
|
+
* native drop behaves exactly like an in-page one. */
|
|
31
|
+
attachFiles?: (files: File[]) => void;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
/**
|
|
@@ -56,12 +56,22 @@ export function ImageViewer({
|
|
|
56
56
|
lightbox = false,
|
|
57
57
|
onRequestClose,
|
|
58
58
|
autoFocus = false,
|
|
59
|
+
fitScale = 1,
|
|
59
60
|
}: ImageViewerProps) {
|
|
60
61
|
const t = useAppT();
|
|
61
62
|
|
|
62
63
|
// `lightbox` implies the in-dialog presentation.
|
|
63
64
|
const isLightbox = lightbox || inDialog;
|
|
64
65
|
|
|
66
|
+
// Cap the at-rest image size to `fitScale` of the viewport in the lightbox so
|
|
67
|
+
// it opens with a margin instead of edge-to-edge. Clamp to a sane range; a
|
|
68
|
+
// value of 1 (the default) is a no-op (falls back to the max-w/h-full class).
|
|
69
|
+
const fit = Math.min(1, Math.max(0.1, fitScale));
|
|
70
|
+
const fitStyle: React.CSSProperties | undefined =
|
|
71
|
+
isLightbox && fit < 1
|
|
72
|
+
? { maxWidth: `${fit * 100}%`, maxHeight: `${fit * 100}%` }
|
|
73
|
+
: undefined;
|
|
74
|
+
|
|
65
75
|
const [currentIndex, setCurrentIndex] = useState(() =>
|
|
66
76
|
Math.max(0, Math.min(initialIndex, images.length - 1))
|
|
67
77
|
);
|
|
@@ -80,6 +90,7 @@ export function ImageViewer({
|
|
|
80
90
|
const [imgDecoded, setImgDecoded] = useState(false);
|
|
81
91
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
82
92
|
const controlsRef = useRef<ReactZoomPanPinchRef | null>(null);
|
|
93
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
83
94
|
|
|
84
95
|
// Idle-fading chrome (lightbox only). Pointer movement over the canvas keeps
|
|
85
96
|
// the controls visible; resting for CHROME_IDLE_MS fades them out.
|
|
@@ -107,10 +118,25 @@ export function ImageViewer({
|
|
|
107
118
|
src: current?.src,
|
|
108
119
|
});
|
|
109
120
|
|
|
110
|
-
// Reset per-source load flags whenever the source changes
|
|
121
|
+
// Reset per-source load flags whenever the source changes.
|
|
122
|
+
//
|
|
123
|
+
// Race guard (first-open stuck on "Loading…"): the `<img onLoad>` handler is
|
|
124
|
+
// the ONLY thing that clears `imgDecoded`, but the browser can finish
|
|
125
|
+
// decoding — and fire `load` — BEFORE React commits the element and attaches
|
|
126
|
+
// that handler (a same-origin blob/attachment URL that resolves synchronously,
|
|
127
|
+
// or an already-cached image). The event is then missed and the spinner hangs
|
|
128
|
+
// forever; reopening "fixes" it only because the fresh mount happens to win
|
|
129
|
+
// the race. So after resetting, synchronously reconcile against the live img:
|
|
130
|
+
// if it's already `complete` with real pixels, mark it decoded now instead of
|
|
131
|
+
// waiting for an onLoad that already fired.
|
|
111
132
|
useEffect(() => {
|
|
112
133
|
setLoadError(false);
|
|
113
|
-
|
|
134
|
+
const img = imgRef.current;
|
|
135
|
+
if (img && img.complete && img.naturalWidth > 0) {
|
|
136
|
+
setImgDecoded(true);
|
|
137
|
+
} else {
|
|
138
|
+
setImgDecoded(false);
|
|
139
|
+
}
|
|
114
140
|
}, [src]);
|
|
115
141
|
|
|
116
142
|
const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
|
|
@@ -344,12 +370,13 @@ export function ImageViewer({
|
|
|
344
370
|
alt=""
|
|
345
371
|
aria-hidden="true"
|
|
346
372
|
className="absolute max-w-full max-h-full object-contain select-none"
|
|
347
|
-
style={{ transform: transformStyle, filter: 'blur(20px)', transition: 'opacity 0.3s ease-out', opacity: isFullyLoaded ? 0 : 1 }}
|
|
373
|
+
style={{ transform: transformStyle, filter: 'blur(20px)', transition: 'opacity 0.3s ease-out', opacity: isFullyLoaded ? 0 : 1, ...fitStyle }}
|
|
348
374
|
draggable={false}
|
|
349
375
|
/>
|
|
350
376
|
)}
|
|
351
377
|
{src && (
|
|
352
378
|
<img
|
|
379
|
+
ref={imgRef}
|
|
353
380
|
src={src}
|
|
354
381
|
alt={current.file.name}
|
|
355
382
|
className={cn(
|
|
@@ -363,6 +390,7 @@ export function ImageViewer({
|
|
|
363
390
|
transition: 'transform 0.15s ease-out, opacity 0.3s ease-out',
|
|
364
391
|
opacity:
|
|
365
392
|
(useProgressiveLoading && !isFullyLoaded) || !imgDecoded ? 0 : 1,
|
|
393
|
+
...fitStyle,
|
|
366
394
|
}}
|
|
367
395
|
draggable={false}
|
|
368
396
|
onLoad={() => setImgDecoded(true)}
|
|
@@ -53,6 +53,15 @@ export interface ImageViewerProps {
|
|
|
53
53
|
lightbox?: boolean;
|
|
54
54
|
/** Close request from the lightbox chrome (Esc / close button). */
|
|
55
55
|
onRequestClose?: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Fraction of the viewport the image may occupy at rest, in the LIGHTBOX
|
|
58
|
+
* presentation only (0 < fitScale ≤ 1). `1` (default) lets the image fill
|
|
59
|
+
* edge-to-edge; `0.8` caps it at 80% so it opens with a comfortable margin
|
|
60
|
+
* (Preview / Quick Look style) instead of slamming into the window edges.
|
|
61
|
+
* Zoom / pan still operate from this fitted size. Ignored in the embedded
|
|
62
|
+
* (non-lightbox) presentation.
|
|
63
|
+
*/
|
|
64
|
+
fitScale?: number;
|
|
56
65
|
/** Focus the viewer container on mount so its keyboard scope is active
|
|
57
66
|
* immediately (zoom/rotate/gallery hotkeys). Pair with `key={src}`
|
|
58
67
|
* upstream when the parent wants a fresh focus per source change. */
|