@hienlh/ppm 0.17.34 → 0.17.36

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/CLAUDE.md +1 -0
  3. package/Dockerfile.test +5 -1
  4. package/assets/skills/ppm/SKILL.md +1 -1
  5. package/assets/skills/ppm/references/http-api.md +1 -1
  6. package/bun.lock +5 -0
  7. package/dist/web/assets/{ai-resource-editor-QNfSU0Fi.js → ai-resource-editor-aT2nC1lK.js} +1 -1
  8. package/dist/web/assets/{audio-preview-IokB77Cj.js → audio-preview-DPS0VpgU.js} +1 -1
  9. package/dist/web/assets/chat-tab-k7e8hE9y.js +13 -0
  10. package/dist/web/assets/{code-editor-B8YiBPOX.js → code-editor-C8ClI1kO.js} +2 -2
  11. package/dist/web/assets/{conflict-editor-CY1o5kBO.js → conflict-editor-Dag7buRT.js} +1 -1
  12. package/dist/web/assets/{csv-preview-AEg0GB-V.js → csv-preview-6JQDYpOU.js} +1 -1
  13. package/dist/web/assets/{database-viewer-C5Meg0s_.js → database-viewer-BFzNauDX.js} +1 -1
  14. package/dist/web/assets/{diff-viewer-BRRGvZnp.js → diff-viewer-Dub9yryk.js} +1 -1
  15. package/dist/web/assets/{docx-preview-DqTX7NDp.js → docx-preview-Bj2Xzdef.js} +1 -1
  16. package/dist/web/assets/{edit-diff-preview-_CXUdwsF.js → edit-diff-preview-D1TEK7Jz.js} +1 -1
  17. package/dist/web/assets/{extension-webview-CQceYb7O.js → extension-webview-DUqZPzfp.js} +1 -1
  18. package/dist/web/assets/{git-log-panel-D7QlAJqu.js → git-log-panel-90_p5Qdn.js} +1 -1
  19. package/dist/web/assets/{glide-data-grid-cGxkgluR.js → glide-data-grid-BSSyHBRx.js} +1 -1
  20. package/dist/web/assets/{group-chat-tab-B-eA5ytm.js → group-chat-tab-j1CoraSo.js} +1 -1
  21. package/dist/web/assets/image-gallery-CObzTisL.js +1 -0
  22. package/dist/web/assets/{image-preview-DRUzYptI.js → image-preview-ChhxwmZU.js} +1 -1
  23. package/dist/web/assets/index-BA8NcuLe.js +50 -0
  24. package/dist/web/assets/index-eW0VEaRF.css +2 -0
  25. package/dist/web/assets/{keybindings-store-B3plqyxF.js → keybindings-store-Bp-oUCTT.js} +1 -1
  26. package/dist/web/assets/markdown-renderer-BaPAXsZW.js +3 -0
  27. package/dist/web/assets/markdown-renderer-CUHI3gc6.js +1 -0
  28. package/dist/web/assets/{notification-store-jFDauzse.js → notification-store-COr3urzj.js} +1 -1
  29. package/dist/web/assets/panel-store-CpKvXNnm.js +1 -0
  30. package/dist/web/assets/{pdf-preview-CzHUqXJB.js → pdf-preview-cJC8Cm9R.js} +1 -1
  31. package/dist/web/assets/{postgres-viewer-BeantJZe.js → postgres-viewer-uMVBwY8R.js} +1 -1
  32. package/dist/web/assets/{settings-store-DFMz_Oer.js → settings-store-CBLPPIts.js} +2 -2
  33. package/dist/web/assets/{settings-tab-CqQHhqV1.js → settings-tab-BdEa4MLs.js} +1 -1
  34. package/dist/web/assets/{sql-query-editor-DqU9zfVy.js → sql-query-editor-R1WsE3n7.js} +1 -1
  35. package/dist/web/assets/{sqlite-viewer-BH891DC0.js → sqlite-viewer-CppHnW_J.js} +1 -1
  36. package/dist/web/assets/{system-monitor-tab-Bue4p4Vb.js → system-monitor-tab-BzK46Q3D.js} +1 -1
  37. package/dist/web/assets/{tab-store-CNxCwdXw.js → tab-store-BQmDVh9c.js} +1 -1
  38. package/dist/web/assets/{terminal-tab-BfNnKU71.js → terminal-tab-2CWIYa05.js} +1 -1
  39. package/dist/web/assets/tool-cards-DS4vQ3qy.js +7 -0
  40. package/dist/web/assets/{use-monaco-theme-5XjAzOzE.js → use-monaco-theme-CEAY5h6Y.js} +1 -1
  41. package/dist/web/assets/{video-preview-DMqLOqmc.js → video-preview-C7RTSYae.js} +1 -1
  42. package/dist/web/index.html +5 -5
  43. package/dist/web/sw.js +1 -1
  44. package/docker-compose.test.yml +5 -1
  45. package/package.json +3 -2
  46. package/src/server/routes/chat.ts +66 -1
  47. package/src/services/image-dimensions.ts +67 -0
  48. package/src/services/transcript-images.ts +174 -0
  49. package/src/shared/tool-result-content.ts +14 -3
  50. package/src/web/components/chat/chat-history-bar.tsx +3 -104
  51. package/src/web/components/chat/message-list.tsx +10 -3
  52. package/src/web/components/chat/session-debug-dialog.tsx +240 -0
  53. package/src/web/components/chat/tool-image-preview.tsx +4 -2
  54. package/src/web/components/group-chat/group-create-dialog.tsx +2 -1
  55. package/src/web/components/layout/editor-panel.tsx +31 -5
  56. package/src/web/components/layout/notification-bell-popover.tsx +5 -1
  57. package/src/web/components/layout/tab-bar.tsx +2 -2
  58. package/src/web/components/shared/image-overlay.tsx +188 -24
  59. package/src/web/components/shared/markdown-context.ts +1 -1
  60. package/src/web/components/shared/markdown-renderer.tsx +3 -1
  61. package/src/web/hooks/use-image-transform.ts +276 -0
  62. package/src/web/lib/clipboard.ts +41 -0
  63. package/src/web/lib/file-download.ts +1 -1
  64. package/src/web/lib/image-gallery.ts +25 -0
  65. package/src/web/stores/image-overlay-store.ts +35 -7
  66. package/src/web/stores/panel-store.ts +40 -6
  67. package/src/web/stores/panel-utils.ts +19 -0
  68. package/dist/web/assets/chat-tab-BZNEvS88.js +0 -13
  69. package/dist/web/assets/index-BuX7JkCt.js +0 -50
  70. package/dist/web/assets/index-PX2pZ3bH.css +0 -2
  71. package/dist/web/assets/markdown-renderer-BKDbjc3g.js +0 -3
  72. package/dist/web/assets/markdown-renderer-C9Gy8tM2.js +0 -1
  73. package/dist/web/assets/panel-store-D-RxcUgH.js +0 -1
  74. package/dist/web/assets/tool-cards-CSOwe7_i.js +0 -7
  75. package/skills-lock.json +0 -11
  76. package/spike-memory-region-dump.ps1 +0 -69
  77. /package/dist/web/assets/{api-themes-BpoKUPkQ.js → api-themes-BLUwA0Gh.js} +0 -0
  78. /package/dist/web/assets/{csv-parser-BPS94T2h.js → csv-parser-DVHBcvuE.js} +0 -0
  79. /package/dist/web/assets/{lib-DA3QSc5_.js → lib-DTaN9riD.js} +0 -0
  80. /package/dist/web/assets/{libesm-BS7D9de3.js → libesm-DdtEjhIi.js} +0 -0
  81. /package/dist/web/assets/{vendor-xterm-CsgYwG03.js → vendor-xterm-QDr0jvpJ.js} +0 -0
@@ -1,45 +1,209 @@
1
- import { useEffect, useCallback } from "react";
2
- import { X } from "lucide-react";
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ Check, ChevronLeft, ChevronRight, Copy, Download, FlipHorizontal, FlipVertical,
4
+ Maximize2, Minus, Plus, RotateCcw, RotateCw, Scan, X,
5
+ } from "lucide-react";
3
6
  import { useImageOverlay } from "@/stores/image-overlay-store";
7
+ import { useImageTransform } from "@/hooks/use-image-transform";
8
+ import { canCopyImage, copyImageToClipboard } from "@/lib/clipboard";
9
+ import { triggerDownload } from "@/lib/file-download";
10
+ import { cn } from "@/lib/utils";
4
11
 
5
- /** Global image lightbox overlay — mount once in app root */
12
+ /**
13
+ * Global image lightbox with zoom, pan, rotate and flip — mount once in app root.
14
+ *
15
+ * The viewer is a separate component so its gesture bindings attach on the same commit that
16
+ * puts the container in the DOM. Running them from here would bind against a ref that is
17
+ * still null while the overlay is closed, and they would never attach at all.
18
+ */
6
19
  export function ImageOverlay() {
7
- const { src, alt, close } = useImageOverlay();
20
+ const src = useImageOverlay((s) => s.src);
21
+ if (!src) return null;
22
+ return <Viewer key={src} />;
23
+ }
8
24
 
9
- const handleKeyDown = useCallback(
10
- (e: KeyboardEvent) => {
11
- if (e.key === "Escape") close();
12
- },
13
- [close],
14
- );
25
+ function Viewer() {
26
+ const { src, alt, images, index, close, go } = useImageOverlay();
27
+ const {
28
+ containerRef, imageRef, zoomPercent, rotation,
29
+ zoomBy, reset, fit, actualSize, rotate, flip,
30
+ } = useImageTransform();
31
+
32
+ const [copied, setCopied] = useState(false);
33
+ const copyable = canCopyImage();
34
+
35
+ // Give the image an explicit identity transform up front, so the first gesture animates
36
+ // from a known state rather than from an empty style. Remounting per image (see the `key`
37
+ // above) is what keeps a 400% zoom from carrying over to the next one.
38
+ useEffect(() => { reset(); }, [reset]);
39
+
40
+ const actions = useRef({ close, go, zoomBy, reset, fit, actualSize, rotate, flip });
41
+ actions.current = { close, go, zoomBy, reset, fit, actualSize, rotate, flip };
15
42
 
16
43
  useEffect(() => {
17
44
  if (!src) return;
18
- document.addEventListener("keydown", handleKeyDown);
19
- return () => document.removeEventListener("keydown", handleKeyDown);
20
- }, [src, handleKeyDown]);
45
+ const onKey = (e: KeyboardEvent) => {
46
+ const a = actions.current;
47
+ const key = e.key;
48
+ if (key === "Escape") return a.close();
49
+ if (key === "ArrowLeft") return a.go(-1);
50
+ if (key === "ArrowRight") return a.go(1);
51
+ if (key === "+" || key === "=") return a.zoomBy(1.25);
52
+ if (key === "-" || key === "_") return a.zoomBy(0.8);
53
+ if (key === "0") return a.fit();
54
+ if (key === "1") return a.actualSize();
55
+ if (key === "r") return a.rotate(90);
56
+ if (key === "R") return a.rotate(-90);
57
+ if (key === "h") return a.flip("x");
58
+ if (key === "v") return a.flip("y");
59
+ };
60
+ document.addEventListener("keydown", onKey);
61
+ return () => document.removeEventListener("keydown", onKey);
62
+ }, [src]);
21
63
 
22
64
  if (!src) return null;
23
65
 
66
+ const name = alt?.trim() || "image";
67
+ const many = images.length > 1;
68
+
69
+ const copy = async () => {
70
+ if (await copyImageToClipboard(src)) {
71
+ setCopied(true);
72
+ setTimeout(() => setCopied(false), 1500);
73
+ }
74
+ };
75
+
24
76
  return (
25
- <div
26
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-150"
27
- onClick={close}
28
- >
77
+ <div className="fixed inset-0 z-[100] flex flex-col bg-black/90 backdrop-blur-sm animate-in fade-in duration-150">
29
78
  <button
30
79
  onClick={close}
31
- className="absolute top-4 right-4 z-10 flex items-center justify-center size-8 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
80
+ className="absolute top-3 right-3 z-20 flex size-11 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 active:bg-white/30"
32
81
  aria-label="Close"
33
82
  >
34
83
  <X className="size-5" />
35
84
  </button>
36
85
 
37
- <img
38
- src={src}
39
- alt={alt}
40
- className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
41
- onClick={(e) => e.stopPropagation()}
42
- />
86
+ {many && (
87
+ <div className="absolute top-3 left-3 z-20 rounded-full bg-white/10 px-3 py-1.5 font-mono text-xs text-white/80">
88
+ {index + 1} / {images.length}
89
+ </div>
90
+ )}
91
+
92
+ {/*
93
+ touch-none hands every finger to the gesture handler; without it the browser claims
94
+ the drag for its own scroll and pinch, and neither pan nor zoom ever fires.
95
+ */}
96
+ <div
97
+ ref={containerRef}
98
+ className="relative flex flex-1 touch-none select-none items-center justify-center overflow-hidden"
99
+ onPointerDown={(e) => { if (e.target === e.currentTarget) e.currentTarget.dataset.bg = "1"; }}
100
+ onPointerUp={(e) => {
101
+ // Close only on a click that started and ended on the backdrop, so releasing a
102
+ // pan outside the image does not dismiss the viewer.
103
+ if (e.target === e.currentTarget && e.currentTarget.dataset.bg === "1") close();
104
+ delete e.currentTarget.dataset.bg;
105
+ }}
106
+ >
107
+ <img
108
+ ref={imageRef}
109
+ src={src}
110
+ alt={alt}
111
+ draggable={false}
112
+ style={{ willChange: "transform" }}
113
+ className="max-h-full max-w-full object-contain rounded-lg shadow-2xl"
114
+ />
115
+ </div>
116
+
117
+ <Toolbar>
118
+ {many && (
119
+ <>
120
+ <ToolButton onClick={() => go(-1)} disabled={index === 0} label="Previous image (←)">
121
+ <ChevronLeft className="size-5" />
122
+ </ToolButton>
123
+ <ToolButton onClick={() => go(1)} disabled={index === images.length - 1} label="Next image (→)">
124
+ <ChevronRight className="size-5" />
125
+ </ToolButton>
126
+ <Divider />
127
+ </>
128
+ )}
129
+
130
+ <ToolButton onClick={() => zoomBy(0.8)} label="Zoom out (-)"><Minus className="size-5" /></ToolButton>
131
+ <button
132
+ onClick={reset}
133
+ className="h-11 min-w-[4.5rem] rounded-lg px-2 font-mono text-xs text-white/80 transition-colors hover:bg-white/10 active:bg-white/20"
134
+ title="Reset view"
135
+ >
136
+ {zoomPercent}%
137
+ </button>
138
+ <ToolButton onClick={() => zoomBy(1.25)} label="Zoom in (+)"><Plus className="size-5" /></ToolButton>
139
+ <ToolButton onClick={fit} label="Fit to screen (0)"><Maximize2 className="size-5" /></ToolButton>
140
+ <ToolButton onClick={actualSize} label="Actual size (1)"><Scan className="size-5" /></ToolButton>
141
+
142
+ <Divider />
143
+
144
+ <ToolButton onClick={() => rotate(-90)} label="Rotate left (Shift+R)" active={rotation !== 0}>
145
+ <RotateCcw className="size-5" />
146
+ </ToolButton>
147
+ <ToolButton onClick={() => rotate(90)} label="Rotate right (R)" active={rotation !== 0}>
148
+ <RotateCw className="size-5" />
149
+ </ToolButton>
150
+ <ToolButton onClick={() => flip("x")} label="Flip horizontally (H)"><FlipHorizontal className="size-5" /></ToolButton>
151
+ <ToolButton onClick={() => flip("y")} label="Flip vertically (V)"><FlipVertical className="size-5" /></ToolButton>
152
+
153
+ <Divider />
154
+
155
+ <ToolButton onClick={() => triggerDownload(src, name)} label="Download"><Download className="size-5" /></ToolButton>
156
+ <ToolButton
157
+ onClick={copy}
158
+ disabled={!copyable}
159
+ label={copyable ? "Copy image" : "Copying images needs an HTTPS or localhost address"}
160
+ >
161
+ {copied ? <Check className="size-5 text-green-400" /> : <Copy className="size-5" />}
162
+ </ToolButton>
163
+ </Toolbar>
164
+ </div>
165
+ );
166
+ }
167
+
168
+ /** Bottom placement keeps the controls in thumb reach on a phone (design-guidelines §9). */
169
+ function Toolbar({ children }: { children: React.ReactNode }) {
170
+ return (
171
+ <div className="flex shrink-0 justify-center px-2 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-2">
172
+ <div className="flex flex-wrap items-center justify-center gap-0.5 rounded-2xl bg-white/10 p-1 backdrop-blur">
173
+ {children}
174
+ </div>
43
175
  </div>
44
176
  );
45
177
  }
178
+
179
+ function Divider() {
180
+ return <span className="mx-1 h-6 w-px bg-white/20" aria-hidden />;
181
+ }
182
+
183
+ function ToolButton({
184
+ onClick, label, children, disabled, active,
185
+ }: {
186
+ onClick: () => void;
187
+ label: string;
188
+ children: React.ReactNode;
189
+ disabled?: boolean;
190
+ active?: boolean;
191
+ }) {
192
+ return (
193
+ <button
194
+ type="button"
195
+ onClick={onClick}
196
+ disabled={disabled}
197
+ title={label}
198
+ aria-label={label}
199
+ className={cn(
200
+ "flex size-11 items-center justify-center rounded-lg text-white transition-colors",
201
+ "hover:bg-white/15 active:bg-white/25",
202
+ active && "text-primary",
203
+ disabled && "pointer-events-none opacity-30",
204
+ )}
205
+ >
206
+ {children}
207
+ </button>
208
+ );
209
+ }
@@ -14,7 +14,7 @@ export interface MdContextValue {
14
14
  /** True while the message is still streaming — defer async Shiki highlight until done. */
15
15
  isStreaming: boolean;
16
16
  openFileOrSearch: (path: string, line?: { start: number; end?: number }) => void;
17
- openImageOverlay: (url: string, alt: string) => void;
17
+ openImageOverlay: (url: string, alt: string, gallery?: { src: string; alt: string }[]) => void;
18
18
  openDiagramOverlay: (svg: string) => void;
19
19
  }
20
20
 
@@ -8,6 +8,7 @@ import rehypeKatex from "rehype-katex";
8
8
  import { useTabStore } from "@/stores/tab-store";
9
9
  import { useFileStore, type FileNode } from "@/stores/file-store";
10
10
  import { useImageOverlay } from "@/stores/image-overlay-store";
11
+ import { collectGallery, GALLERY_ITEM_ATTR } from "@/lib/image-gallery";
11
12
  import { useDiagramOverlay } from "@/stores/diagram-overlay-store";
12
13
  import { openCommandPalette } from "@/hooks/use-global-keybindings";
13
14
  import { useBackgroundOutputStore } from "@/stores/background-output-store";
@@ -162,7 +163,8 @@ function MdImage({ src, alt, node, ...props }: any) {
162
163
  <img
163
164
  src={displaySrc}
164
165
  alt={name}
165
- onClick={() => displaySrc && openImageOverlay(displaySrc, name)}
166
+ {...{ [GALLERY_ITEM_ATTR]: "" }}
167
+ onClick={(e) => displaySrc && openImageOverlay(displaySrc, name, collectGallery(e.currentTarget))}
166
168
  className="max-h-[400px] max-w-full object-contain rounded-md border border-border cursor-pointer"
167
169
  style={{ opacity: loading ? 0.3 : 1, minHeight: loading ? 48 : undefined, minWidth: loading ? 48 : undefined }}
168
170
  {...props}
@@ -0,0 +1,276 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useGesture } from "@use-gesture/react";
3
+
4
+ /**
5
+ * Pan / zoom / rotate / flip state for a single image inside a fixed container.
6
+ *
7
+ * The transform is written straight to the element's style on every gesture frame and only
8
+ * mirrored into React state for the zoom readout. Re-rendering a component tree per
9
+ * pointermove is what makes a viewer feel heavy, and none of the surrounding UI depends on
10
+ * the intermediate values.
11
+ *
12
+ * `scale` is relative to the image's laid-out size, not its pixels: the `<img>` is already
13
+ * CSS-fitted to the container, so scale 1 means "fits" and the fit never has to be measured
14
+ * for the unrotated case.
15
+ */
16
+
17
+ export interface ImageTransform {
18
+ scale: number;
19
+ x: number;
20
+ y: number;
21
+ /** Multiples of 90 degrees, always normalised to 0/90/180/270. */
22
+ rotation: number;
23
+ flipX: boolean;
24
+ flipY: boolean;
25
+ }
26
+
27
+ const IDENTITY: ImageTransform = { scale: 1, x: 0, y: 0, rotation: 0, flipX: false, flipY: false };
28
+
29
+ const MIN_SCALE = 0.1;
30
+ const MAX_SCALE = 20;
31
+ /** Scale applied when double-clicking an image that is currently at fit. */
32
+ const DOUBLE_CLICK_SCALE = 2.5;
33
+
34
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
35
+
36
+ /**
37
+ * Whether a wheel event looks like a real mouse wheel rather than a trackpad.
38
+ *
39
+ * Trackpads emit a dense stream of small, often fractional deltas and frequently move on both
40
+ * axes; a wheel emits sparse whole-number notches on one axis. The guess only decides whether
41
+ * a bare wheel zooms or pans — Ctrl/Cmd+wheel, the toolbar and the keyboard always zoom, so a
42
+ * wrong guess is an annoyance rather than a dead end.
43
+ */
44
+ function looksLikeMouseWheel(e: WheelEvent): boolean {
45
+ if (e.deltaMode !== 0) return true; // line/page deltas only ever come from a wheel
46
+ if (e.deltaX !== 0) return false;
47
+ return Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40;
48
+ }
49
+
50
+ export interface UseImageTransformResult {
51
+ containerRef: React.RefObject<HTMLDivElement | null>;
52
+ imageRef: React.RefObject<HTMLImageElement | null>;
53
+ /** Current zoom as a percentage of the fitted size, for display only. */
54
+ zoomPercent: number;
55
+ rotation: number;
56
+ zoomBy: (factor: number) => void;
57
+ reset: () => void;
58
+ fit: () => void;
59
+ actualSize: () => void;
60
+ rotate: (deltaDegrees: number) => void;
61
+ flip: (axis: "x" | "y") => void;
62
+ }
63
+
64
+ export function useImageTransform(): UseImageTransformResult {
65
+ const containerRef = useRef<HTMLDivElement | null>(null);
66
+ const imageRef = useRef<HTMLImageElement | null>(null);
67
+ const t = useRef<ImageTransform>({ ...IDENTITY });
68
+ const frame = useRef<number | null>(null);
69
+
70
+ const [zoomPercent, setZoomPercent] = useState(100);
71
+ const [rotation, setRotation] = useState(0);
72
+
73
+ /** Laid-out size of the image — unaffected by transforms, so it is the stable reference. */
74
+ const layout = useCallback(() => {
75
+ const img = imageRef.current;
76
+ const box = containerRef.current;
77
+ return {
78
+ dw: img?.clientWidth ?? 0,
79
+ dh: img?.clientHeight ?? 0,
80
+ cw: box?.clientWidth ?? 0,
81
+ ch: box?.clientHeight ?? 0,
82
+ natural: img?.naturalWidth ?? 0,
83
+ };
84
+ }, []);
85
+
86
+ /** How far the image may travel before its edge crosses the container's. */
87
+ const panLimits = useCallback(() => {
88
+ const { dw, dh, cw, ch } = layout();
89
+ const quarterTurned = Math.abs(t.current.rotation % 180) === 90;
90
+ const w = (quarterTurned ? dh : dw) * t.current.scale;
91
+ const h = (quarterTurned ? dw : dh) * t.current.scale;
92
+ return { maxX: Math.max(0, (w - cw) / 2), maxY: Math.max(0, (h - ch) / 2) };
93
+ }, [layout]);
94
+
95
+ const commit = useCallback(() => {
96
+ setZoomPercent(Math.round(t.current.scale * 100));
97
+ setRotation(t.current.rotation);
98
+ }, []);
99
+
100
+ /**
101
+ * Write the transform, and publish the readout.
102
+ *
103
+ * Mid-gesture the readout is throttled to one frame, since it is cosmetic and re-rendering
104
+ * per pointermove is what makes a viewer feel heavy. At rest it must be published outright:
105
+ * a frame callback only runs if the browser goes on to paint, so a gesture that ends the
106
+ * moment the finger lifts would otherwise leave the number showing the previous value.
107
+ */
108
+ const apply = useCallback((immediate = false) => {
109
+ const el = imageRef.current;
110
+ if (!el) return;
111
+ const { scale, x, y, rotation: r, flipX, flipY } = t.current;
112
+ el.style.transform =
113
+ `translate3d(${x}px, ${y}px, 0) rotate(${r}deg) ` +
114
+ `scale(${scale * (flipX ? -1 : 1)}, ${scale * (flipY ? -1 : 1)})`;
115
+
116
+ if (immediate) {
117
+ if (frame.current != null) {
118
+ cancelAnimationFrame(frame.current);
119
+ frame.current = null;
120
+ }
121
+ commit();
122
+ return;
123
+ }
124
+ if (frame.current == null) {
125
+ frame.current = requestAnimationFrame(() => {
126
+ frame.current = null;
127
+ commit();
128
+ });
129
+ }
130
+ }, [commit]);
131
+
132
+ /** Clamp the pan back inside the limits — used after any scale or rotation change. */
133
+ const settle = useCallback((immediate = false) => {
134
+ const { maxX, maxY } = panLimits();
135
+ t.current.x = clamp(t.current.x, -maxX, maxX);
136
+ t.current.y = clamp(t.current.y, -maxY, maxY);
137
+ apply(immediate);
138
+ }, [apply, panLimits]);
139
+
140
+ /**
141
+ * Zoom while holding one point of the image still.
142
+ *
143
+ * `originX/Y` are client coordinates; without this correction the image slides away from
144
+ * whatever the user was pointing at, which is the single most noticeable flaw in a viewer.
145
+ */
146
+ const zoomAt = useCallback((nextScale: number, originX?: number, originY?: number, immediate = false) => {
147
+ const box = containerRef.current;
148
+ if (!box) return;
149
+ const rect = box.getBoundingClientRect();
150
+ const cx = (originX ?? rect.left + rect.width / 2) - (rect.left + rect.width / 2);
151
+ const cy = (originY ?? rect.top + rect.height / 2) - (rect.top + rect.height / 2);
152
+
153
+ const prev = t.current.scale;
154
+ const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
155
+ const ratio = scale / prev;
156
+ t.current.scale = scale;
157
+ t.current.x = cx - (cx - t.current.x) * ratio;
158
+ t.current.y = cy - (cy - t.current.y) * ratio;
159
+ settle(immediate);
160
+ }, [settle]);
161
+
162
+ const reset = useCallback(() => {
163
+ t.current = { ...IDENTITY };
164
+ apply(true);
165
+ }, [apply]);
166
+
167
+ /**
168
+ * Scale the image so its rotated bounding box fits the container.
169
+ *
170
+ * At 0 and 180 degrees CSS has already fitted it, so the answer is 1. At a quarter turn the
171
+ * width and height swap and the fitted box no longer fits.
172
+ */
173
+ const fit = useCallback(() => {
174
+ const { dw, dh, cw, ch } = layout();
175
+ const quarterTurned = Math.abs(t.current.rotation % 180) === 90;
176
+ t.current.scale = quarterTurned && dw > 0 && dh > 0 ? Math.min(cw / dh, ch / dw) : 1;
177
+ t.current.x = 0;
178
+ t.current.y = 0;
179
+ settle(true);
180
+ }, [layout, settle]);
181
+
182
+ /** One image pixel per CSS pixel. */
183
+ const actualSize = useCallback(() => {
184
+ const { dw, natural } = layout();
185
+ if (dw > 0 && natural > 0) zoomAt(natural / dw, undefined, undefined, true);
186
+ }, [layout, zoomAt]);
187
+
188
+ const rotate = useCallback((delta: number) => {
189
+ t.current.rotation = ((t.current.rotation + delta) % 360 + 360) % 360;
190
+ // A quarter turn changes which dimension is constrained, so an untouched view would
191
+ // otherwise overflow. Re-fitting keeps the whole image visible, which is the reason
192
+ // someone rotates in the first place.
193
+ fit();
194
+ }, [fit]);
195
+
196
+ const flip = useCallback((axis: "x" | "y") => {
197
+ if (axis === "x") t.current.flipX = !t.current.flipX;
198
+ else t.current.flipY = !t.current.flipY;
199
+ apply(true);
200
+ }, [apply]);
201
+
202
+ const zoomBy = useCallback(
203
+ (factor: number) => zoomAt(t.current.scale * factor, undefined, undefined, true),
204
+ [zoomAt],
205
+ );
206
+
207
+ // Bound to the container element itself rather than returning props to spread: only a
208
+ // natively attached, non-passive listener may call preventDefault on wheel, and React's
209
+ // synthetic wheel handler is always passive.
210
+ useGesture(
211
+ {
212
+ onDrag: ({ delta: [dx, dy], pinching, cancel, last }) => {
213
+ if (pinching) return cancel();
214
+ t.current.x += dx;
215
+ t.current.y += dy;
216
+ settle(last);
217
+ },
218
+ onPinch: ({ offset: [s], origin: [ox, oy], first, last, memo }) => {
219
+ // `offset` is cumulative from the gesture's start, so anchor it to the scale we had.
220
+ const base = first ? t.current.scale / (s || 1) : memo ?? 1;
221
+ zoomAt(base * s, ox, oy, last);
222
+ return base;
223
+ },
224
+ onWheel: ({ event, last }) => {
225
+ // A wheel gesture closes with a trailing frame that carries the previous event
226
+ // object, deltas and all. Acting on it would apply every notch twice — but it is the
227
+ // right moment to publish the readout, since no further frame is guaranteed.
228
+ if (last) {
229
+ apply(true);
230
+ return;
231
+ }
232
+ // Read the deltas off the event rather than the gesture state, which accumulates.
233
+ const { deltaX, deltaY } = event;
234
+ if (deltaX === 0 && deltaY === 0) return;
235
+ event.preventDefault();
236
+
237
+ // A trackpad pinch reaches the page as Ctrl+wheel — the browser exposes no separate
238
+ // pinch event outside of Safari, and @use-gesture's pinch recogniser does not claim
239
+ // it here, so this is the only place it can be handled.
240
+ if (event.ctrlKey || event.metaKey || looksLikeMouseWheel(event)) {
241
+ // Exponential so every notch scales by the same proportion, and so a trackpad's
242
+ // stream of small fractional deltas reads as one continuous zoom.
243
+ zoomAt(t.current.scale * Math.exp(-deltaY / 300), event.clientX, event.clientY);
244
+ return;
245
+ }
246
+
247
+ t.current.x -= deltaX;
248
+ t.current.y -= deltaY;
249
+ settle();
250
+ },
251
+ onDoubleClick: ({ event }) => {
252
+ if (t.current.scale > 1.05) fit();
253
+ else zoomAt(DOUBLE_CLICK_SCALE, event.clientX, event.clientY, true);
254
+ },
255
+ },
256
+ {
257
+ target: containerRef,
258
+ drag: { from: () => [t.current.x, t.current.y], filterTaps: true, pointer: { touch: true } },
259
+ pinch: { scaleBounds: { min: MIN_SCALE, max: MAX_SCALE }, rubberband: true },
260
+ eventOptions: { passive: false },
261
+ },
262
+ );
263
+
264
+ // A resized window changes what "fits", and a stale pan clamp would leave the image
265
+ // stranded off-centre.
266
+ useEffect(() => {
267
+ const onResize = () => settle();
268
+ window.addEventListener("resize", onResize);
269
+ return () => {
270
+ window.removeEventListener("resize", onResize);
271
+ if (frame.current != null) cancelAnimationFrame(frame.current);
272
+ };
273
+ }, [settle]);
274
+
275
+ return { containerRef, imageRef, zoomPercent, rotation, zoomBy, reset, fit, actualSize, rotate, flip };
276
+ }
@@ -20,6 +20,47 @@ export async function copyToClipboard(text: string): Promise<boolean> {
20
20
  return legacyCopy(text);
21
21
  }
22
22
 
23
+ /**
24
+ * Whether an image can be placed on the clipboard at all on this origin.
25
+ *
26
+ * Unlike text there is no `execCommand` fallback for binary data, so on the plain-HTTP
27
+ * origins PPM is often reached from there is nothing to try — callers should disable the
28
+ * action and say why rather than offer a button that always fails.
29
+ */
30
+ export function canCopyImage(): boolean {
31
+ return typeof ClipboardItem !== "undefined" && !!navigator.clipboard?.write;
32
+ }
33
+
34
+ /**
35
+ * Copy an image to the clipboard, converting to PNG first.
36
+ *
37
+ * Browsers accept a very short list of image types on the clipboard — PNG is the only one
38
+ * supported everywhere — so a JPEG or WebP has to be repainted through a canvas.
39
+ */
40
+ export async function copyImageToClipboard(src: string): Promise<boolean> {
41
+ if (!canCopyImage()) return false;
42
+ try {
43
+ const blob = await fetch(src).then((r) => r.blob());
44
+ const png = blob.type === "image/png" ? blob : await toPng(blob);
45
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ async function toPng(blob: Blob): Promise<Blob> {
53
+ const bitmap = await createImageBitmap(blob);
54
+ const canvas = document.createElement("canvas");
55
+ canvas.width = bitmap.width;
56
+ canvas.height = bitmap.height;
57
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
58
+ bitmap.close();
59
+ return new Promise((resolve, reject) => {
60
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("canvas produced no blob"))), "image/png");
61
+ });
62
+ }
63
+
23
64
  function legacyCopy(text: string): boolean {
24
65
  const textarea = document.createElement("textarea");
25
66
  textarea.value = text;
@@ -24,7 +24,7 @@ export async function downloadFolder(projectName: string, folderPath: string): P
24
24
  }
25
25
 
26
26
  /** Hidden <a> tag download trigger — avoids popup blockers */
27
- function triggerDownload(url: string, filename: string): void {
27
+ export function triggerDownload(url: string, filename: string): void {
28
28
  const a = document.createElement("a");
29
29
  a.href = url;
30
30
  a.download = filename;
@@ -0,0 +1,25 @@
1
+ import type { GalleryImage } from "@/stores/image-overlay-store";
2
+
3
+ /** Marks a container whose tagged images form one navigable set. */
4
+ export const GALLERY_ROOT_ATTR = "data-image-gallery";
5
+ /** Marks an `<img>` that belongs to the enclosing gallery. */
6
+ export const GALLERY_ITEM_ATTR = "data-gallery-item";
7
+
8
+ /**
9
+ * Collect the images a viewer may move between, in the order they appear on screen.
10
+ *
11
+ * Read from the DOM rather than a registry because every image already owns a blob URL that
12
+ * is revoked when its component unmounts — so the mounted elements are exactly the set whose
13
+ * sources are still valid, and the DOM already holds them in the order the user sees.
14
+ *
15
+ * The reach is therefore whatever is mounted. The chat transcript is deliberately not
16
+ * virtualised (`message-list.tsx`), so that is the whole conversation today; if it ever gains
17
+ * data windowing, navigation narrows to the loaded window along with everything else.
18
+ */
19
+ export function collectGallery(from: Element | null): GalleryImage[] {
20
+ const root = from?.closest(`[${GALLERY_ROOT_ATTR}]`);
21
+ if (!root) return [];
22
+ return [...root.querySelectorAll<HTMLImageElement>(`img[${GALLERY_ITEM_ATTR}]`)]
23
+ .filter((img) => !!img.src)
24
+ .map((img) => ({ src: img.src, alt: img.alt }));
25
+ }
@@ -1,18 +1,46 @@
1
1
  import { create } from "zustand";
2
2
 
3
+ export interface GalleryImage {
4
+ /** Blob URL or data URL of the image. */
5
+ src: string;
6
+ alt: string;
7
+ }
8
+
3
9
  interface ImageOverlayState {
4
- /** Blob URL or data URL of the image to display */
10
+ /** Images the overlay can move between; a single entry when opened without a gallery. */
11
+ images: GalleryImage[];
12
+ index: number;
13
+ /** Convenience mirrors of the current entry — every existing consumer reads these. */
5
14
  src: string | null;
6
15
  alt: string;
7
- /** Open the overlay with a given image source */
8
- open: (src: string, alt?: string) => void;
9
- /** Close the overlay */
16
+ /**
17
+ * Open the overlay. Passing a gallery lets the arrows walk its siblings; the clicked
18
+ * source decides where in that list the viewer starts.
19
+ */
20
+ open: (src: string, alt?: string, gallery?: GalleryImage[]) => void;
10
21
  close: () => void;
22
+ /** Move by a signed offset, stopping at the ends rather than wrapping. */
23
+ go: (delta: number) => void;
11
24
  }
12
25
 
13
- export const useImageOverlay = create<ImageOverlayState>((set) => ({
26
+ export const useImageOverlay = create<ImageOverlayState>((set, get) => ({
27
+ images: [],
28
+ index: 0,
14
29
  src: null,
15
30
  alt: "",
16
- open: (src, alt = "") => set({ src, alt }),
17
- close: () => set({ src: null, alt: "" }),
31
+ open: (src, alt = "", gallery) => {
32
+ const images = gallery?.length ? gallery : [{ src, alt }];
33
+ const found = images.findIndex((i) => i.src === src);
34
+ const index = found >= 0 ? found : 0;
35
+ const current = images[index]!;
36
+ set({ images, index, src: current.src, alt: current.alt || alt });
37
+ },
38
+ close: () => set({ images: [], index: 0, src: null, alt: "" }),
39
+ go: (delta) => {
40
+ const { images, index } = get();
41
+ const next = index + delta;
42
+ if (next < 0 || next >= images.length) return;
43
+ const current = images[next]!;
44
+ set({ index: next, src: current.src, alt: current.alt });
45
+ },
18
46
  }));