@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.
- package/CHANGELOG.md +12 -0
- package/CLAUDE.md +1 -0
- package/Dockerfile.test +5 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +5 -0
- package/dist/web/assets/{ai-resource-editor-QNfSU0Fi.js → ai-resource-editor-aT2nC1lK.js} +1 -1
- package/dist/web/assets/{audio-preview-IokB77Cj.js → audio-preview-DPS0VpgU.js} +1 -1
- package/dist/web/assets/chat-tab-k7e8hE9y.js +13 -0
- package/dist/web/assets/{code-editor-B8YiBPOX.js → code-editor-C8ClI1kO.js} +2 -2
- package/dist/web/assets/{conflict-editor-CY1o5kBO.js → conflict-editor-Dag7buRT.js} +1 -1
- package/dist/web/assets/{csv-preview-AEg0GB-V.js → csv-preview-6JQDYpOU.js} +1 -1
- package/dist/web/assets/{database-viewer-C5Meg0s_.js → database-viewer-BFzNauDX.js} +1 -1
- package/dist/web/assets/{diff-viewer-BRRGvZnp.js → diff-viewer-Dub9yryk.js} +1 -1
- package/dist/web/assets/{docx-preview-DqTX7NDp.js → docx-preview-Bj2Xzdef.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-_CXUdwsF.js → edit-diff-preview-D1TEK7Jz.js} +1 -1
- package/dist/web/assets/{extension-webview-CQceYb7O.js → extension-webview-DUqZPzfp.js} +1 -1
- package/dist/web/assets/{git-log-panel-D7QlAJqu.js → git-log-panel-90_p5Qdn.js} +1 -1
- package/dist/web/assets/{glide-data-grid-cGxkgluR.js → glide-data-grid-BSSyHBRx.js} +1 -1
- package/dist/web/assets/{group-chat-tab-B-eA5ytm.js → group-chat-tab-j1CoraSo.js} +1 -1
- package/dist/web/assets/image-gallery-CObzTisL.js +1 -0
- package/dist/web/assets/{image-preview-DRUzYptI.js → image-preview-ChhxwmZU.js} +1 -1
- package/dist/web/assets/index-BA8NcuLe.js +50 -0
- package/dist/web/assets/index-eW0VEaRF.css +2 -0
- package/dist/web/assets/{keybindings-store-B3plqyxF.js → keybindings-store-Bp-oUCTT.js} +1 -1
- package/dist/web/assets/markdown-renderer-BaPAXsZW.js +3 -0
- package/dist/web/assets/markdown-renderer-CUHI3gc6.js +1 -0
- package/dist/web/assets/{notification-store-jFDauzse.js → notification-store-COr3urzj.js} +1 -1
- package/dist/web/assets/panel-store-CpKvXNnm.js +1 -0
- package/dist/web/assets/{pdf-preview-CzHUqXJB.js → pdf-preview-cJC8Cm9R.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BeantJZe.js → postgres-viewer-uMVBwY8R.js} +1 -1
- package/dist/web/assets/{settings-store-DFMz_Oer.js → settings-store-CBLPPIts.js} +2 -2
- package/dist/web/assets/{settings-tab-CqQHhqV1.js → settings-tab-BdEa4MLs.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DqU9zfVy.js → sql-query-editor-R1WsE3n7.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BH891DC0.js → sqlite-viewer-CppHnW_J.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-Bue4p4Vb.js → system-monitor-tab-BzK46Q3D.js} +1 -1
- package/dist/web/assets/{tab-store-CNxCwdXw.js → tab-store-BQmDVh9c.js} +1 -1
- package/dist/web/assets/{terminal-tab-BfNnKU71.js → terminal-tab-2CWIYa05.js} +1 -1
- package/dist/web/assets/tool-cards-DS4vQ3qy.js +7 -0
- package/dist/web/assets/{use-monaco-theme-5XjAzOzE.js → use-monaco-theme-CEAY5h6Y.js} +1 -1
- package/dist/web/assets/{video-preview-DMqLOqmc.js → video-preview-C7RTSYae.js} +1 -1
- package/dist/web/index.html +5 -5
- package/dist/web/sw.js +1 -1
- package/docker-compose.test.yml +5 -1
- package/package.json +3 -2
- package/src/server/routes/chat.ts +66 -1
- package/src/services/image-dimensions.ts +67 -0
- package/src/services/transcript-images.ts +174 -0
- package/src/shared/tool-result-content.ts +14 -3
- package/src/web/components/chat/chat-history-bar.tsx +3 -104
- package/src/web/components/chat/message-list.tsx +10 -3
- package/src/web/components/chat/session-debug-dialog.tsx +240 -0
- package/src/web/components/chat/tool-image-preview.tsx +4 -2
- package/src/web/components/group-chat/group-create-dialog.tsx +2 -1
- package/src/web/components/layout/editor-panel.tsx +31 -5
- package/src/web/components/layout/notification-bell-popover.tsx +5 -1
- package/src/web/components/layout/tab-bar.tsx +2 -2
- package/src/web/components/shared/image-overlay.tsx +188 -24
- package/src/web/components/shared/markdown-context.ts +1 -1
- package/src/web/components/shared/markdown-renderer.tsx +3 -1
- package/src/web/hooks/use-image-transform.ts +276 -0
- package/src/web/lib/clipboard.ts +41 -0
- package/src/web/lib/file-download.ts +1 -1
- package/src/web/lib/image-gallery.ts +25 -0
- package/src/web/stores/image-overlay-store.ts +35 -7
- package/src/web/stores/panel-store.ts +40 -6
- package/src/web/stores/panel-utils.ts +19 -0
- package/dist/web/assets/chat-tab-BZNEvS88.js +0 -13
- package/dist/web/assets/index-BuX7JkCt.js +0 -50
- package/dist/web/assets/index-PX2pZ3bH.css +0 -2
- package/dist/web/assets/markdown-renderer-BKDbjc3g.js +0 -3
- package/dist/web/assets/markdown-renderer-C9Gy8tM2.js +0 -1
- package/dist/web/assets/panel-store-D-RxcUgH.js +0 -1
- package/dist/web/assets/tool-cards-CSOwe7_i.js +0 -7
- package/skills-lock.json +0 -11
- package/spike-memory-region-dump.ps1 +0 -69
- /package/dist/web/assets/{api-themes-BpoKUPkQ.js → api-themes-BLUwA0Gh.js} +0 -0
- /package/dist/web/assets/{csv-parser-BPS94T2h.js → csv-parser-DVHBcvuE.js} +0 -0
- /package/dist/web/assets/{lib-DA3QSc5_.js → lib-DTaN9riD.js} +0 -0
- /package/dist/web/assets/{libesm-BS7D9de3.js → libesm-DdtEjhIi.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-CsgYwG03.js → vendor-xterm-QDr0jvpJ.js} +0 -0
|
@@ -1,45 +1,209 @@
|
|
|
1
|
-
import { useEffect,
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
20
|
+
const src = useImageOverlay((s) => s.src);
|
|
21
|
+
if (!src) return null;
|
|
22
|
+
return <Viewer key={src} />;
|
|
23
|
+
}
|
|
8
24
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/web/lib/clipboard.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
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 = "") =>
|
|
17
|
-
|
|
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
|
}));
|