@selvajs/ui 6.6.0-beta.3 → 6.6.0-beta.5
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/components/ErrorScreen.svelte +6 -2
- package/dist/components/compute/AppLayout.svelte +157 -71
- package/dist/components/compute/AppLayout.svelte.d.ts +6 -0
- package/dist/components/compute/ComputeApp.svelte +39 -20
- package/dist/components/compute/ComputeApp.svelte.d.ts +19 -2
- package/dist/components/compute/ComputeMessagesDialog.svelte +30 -40
- package/dist/components/compute/ParameterPresetManager.svelte +7 -44
- package/dist/components/compute/ParameterPresetManager.svelte.d.ts +4 -5
- package/dist/components/compute/SolveMessages.svelte +30 -37
- package/dist/components/compute/SolveMessages.svelte.d.ts +0 -12
- package/dist/components/layout/PageFooter.svelte +21 -11
- package/dist/components/preview/ChartOutput.svelte +12 -7
- package/dist/components/preview/Group.svelte +33 -18
- package/dist/components/preview/HelpButton.svelte +4 -2
- package/dist/components/preview/ImageOutput.svelte +427 -68
- package/dist/components/preview/InputControl.svelte +11 -2
- package/dist/components/preview/OutputDisplay.svelte +17 -12
- package/dist/components/preview/TabContent.svelte +3 -0
- package/dist/components/preview/inputs/ColorInput.svelte +3 -1
- package/dist/components/preview/inputs/ColorInput.svelte.d.ts +1 -0
- package/dist/components/preview/inputs/DropdownInput.svelte +4 -1
- package/dist/components/preview/inputs/FileInput.svelte +50 -34
- package/dist/components/preview/inputs/NumberInput.svelte +92 -10
- package/dist/components/preview/inputs/NumberInput.svelte.d.ts +2 -0
- package/dist/components/preview/inputs/TextInput.svelte +8 -3
- package/dist/components/primitives/CalculateButton.svelte +49 -23
- package/dist/components/primitives/CalculateButton.svelte.d.ts +3 -0
- package/dist/components/primitives/SolvingIndicator.svelte +21 -17
- package/dist/components/primitives/badge/badge.svelte +2 -2
- package/dist/components/primitives/badge/badge.svelte.d.ts +4 -4
- package/dist/components/primitives/button/button.svelte +2 -2
- package/dist/components/primitives/button/button.svelte.d.ts +4 -4
- package/dist/components/primitives/checkbox/checkbox.svelte +1 -1
- package/dist/components/primitives/dialog/dialog-content.svelte +6 -2
- package/dist/components/primitives/dropdown-menu/dropdown-menu-content.svelte +1 -1
- package/dist/components/primitives/dropdown-menu/dropdown-menu-sub-content.svelte +1 -1
- package/dist/components/primitives/dropdown-menu/dropdown-menu-sub-trigger.svelte +1 -1
- package/dist/components/primitives/input/input.svelte +2 -2
- package/dist/components/primitives/input-group/input-group.svelte +1 -1
- package/dist/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
- package/dist/components/primitives/popover/popover-content.svelte +1 -1
- package/dist/components/primitives/scroll-area/scroll-area.svelte +1 -1
- package/dist/components/primitives/search/search.svelte +1 -1
- package/dist/components/primitives/select/select-trigger.svelte +1 -1
- package/dist/components/primitives/slider/slider.svelte +7 -2
- package/dist/components/primitives/slider/slider.svelte.d.ts +5 -1
- package/dist/components/primitives/switch/switch.svelte +1 -1
- package/dist/components/primitives/tabs/tabs-trigger.svelte +1 -1
- package/dist/components/primitives/tabs/tabs-trigger.svelte.d.ts +1 -1
- package/dist/components/primitives/textarea/textarea.svelte +1 -1
- package/dist/components/primitives/toggle/toggle.svelte +1 -1
- package/dist/components/primitives/toggle/toggle.svelte.d.ts +1 -1
- package/dist/components/primitives/toggle-group/toggle-group-item.svelte +1 -1
- package/dist/components/primitives/toggle-group/toggle-group.svelte +1 -1
- package/dist/components/viewer/Viewer.svelte +20 -17
- package/dist/compute/run-panel-action.d.ts +8 -0
- package/dist/compute/run-panel-action.js +12 -0
- package/dist/compute/useSolveSession.svelte.js +3 -0
- package/dist/i18n/localeContext.svelte.d.ts +10 -3
- package/dist/i18n/localeContext.svelte.js +27 -5
- package/dist/i18n/messages.d.ts +115 -2
- package/dist/i18n/messages.js +199 -11
- package/dist/index.d.ts +1 -1
- package/dist/public.d.ts +1 -1
- package/dist/schema/visibility-rules.js +21 -0
- package/dist/styles/base.css +9 -6
- package/dist/types/actionButton.d.ts +17 -3
- package/dist/types/presetLabels.d.ts +3 -2
- package/dist/types/presetLabels.js +9 -8
- package/package.json +8 -8
- package/src/lib/components/ErrorScreen.svelte +6 -2
- package/src/lib/components/compute/AppLayout.svelte +157 -71
- package/src/lib/components/compute/ComputeApp.svelte +39 -20
- package/src/lib/components/compute/ComputeMessagesDialog.svelte +30 -40
- package/src/lib/components/compute/ParameterPresetManager.svelte +7 -44
- package/src/lib/components/compute/SolveMessages.svelte +30 -37
- package/src/lib/components/layout/PageFooter.svelte +21 -11
- package/src/lib/components/preview/ChartOutput.svelte +12 -7
- package/src/lib/components/preview/Group.svelte +33 -18
- package/src/lib/components/preview/HelpButton.svelte +4 -2
- package/src/lib/components/preview/ImageOutput.svelte +427 -68
- package/src/lib/components/preview/InputControl.svelte +11 -2
- package/src/lib/components/preview/OutputDisplay.svelte +17 -12
- package/src/lib/components/preview/TabContent.svelte +3 -0
- package/src/lib/components/preview/inputs/ColorInput.svelte +3 -1
- package/src/lib/components/preview/inputs/DropdownInput.svelte +4 -1
- package/src/lib/components/preview/inputs/FileInput.svelte +50 -34
- package/src/lib/components/preview/inputs/NumberInput.svelte +92 -10
- package/src/lib/components/preview/inputs/TextInput.svelte +8 -3
- package/src/lib/components/primitives/CalculateButton.svelte +49 -23
- package/src/lib/components/primitives/SolvingIndicator.svelte +21 -17
- package/src/lib/components/primitives/badge/badge.svelte +2 -2
- package/src/lib/components/primitives/button/button.svelte +2 -2
- package/src/lib/components/primitives/checkbox/checkbox.svelte +1 -1
- package/src/lib/components/primitives/dialog/dialog-content.svelte +6 -2
- package/src/lib/components/primitives/dropdown-menu/dropdown-menu-content.svelte +1 -1
- package/src/lib/components/primitives/dropdown-menu/dropdown-menu-sub-content.svelte +1 -1
- package/src/lib/components/primitives/dropdown-menu/dropdown-menu-sub-trigger.svelte +1 -1
- package/src/lib/components/primitives/input/input.svelte +2 -2
- package/src/lib/components/primitives/input-group/input-group.svelte +1 -1
- package/src/lib/components/primitives/mode-toggle/mode-toggle.svelte +4 -1
- package/src/lib/components/primitives/popover/popover-content.svelte +1 -1
- package/src/lib/components/primitives/scroll-area/scroll-area.svelte +1 -1
- package/src/lib/components/primitives/search/search.svelte +1 -1
- package/src/lib/components/primitives/select/select-trigger.svelte +1 -1
- package/src/lib/components/primitives/slider/slider.svelte +7 -2
- package/src/lib/components/primitives/switch/switch.svelte +1 -1
- package/src/lib/components/primitives/tabs/tabs-trigger.svelte +1 -1
- package/src/lib/components/primitives/textarea/textarea.svelte +1 -1
- package/src/lib/components/primitives/toggle/toggle.svelte +1 -1
- package/src/lib/components/primitives/toggle-group/toggle-group-item.svelte +1 -1
- package/src/lib/components/primitives/toggle-group/toggle-group.svelte +1 -1
- package/src/lib/components/viewer/Viewer.svelte +20 -17
- package/src/lib/compute/run-panel-action.ts +16 -0
- package/src/lib/compute/useSolveSession.svelte.ts +3 -0
- package/src/lib/i18n/localeContext.svelte.ts +40 -5
- package/src/lib/i18n/messages.ts +351 -13
- package/src/lib/index.ts +1 -0
- package/src/lib/public.ts +1 -0
- package/src/lib/schema/visibility-rules.ts +23 -0
- package/src/lib/styles/base.css +9 -6
- package/src/lib/types/actionButton.ts +16 -3
- package/src/lib/types/presetLabels.ts +13 -11
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { OutputImageLayoutItem } from '@selvajs/schemas';
|
|
3
3
|
import type { FileData } from '@selvajs/compute/core';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ChevronLeft,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
Download,
|
|
8
|
+
LoaderCircle,
|
|
9
|
+
Maximize,
|
|
10
|
+
Minimize,
|
|
11
|
+
Scan,
|
|
12
|
+
ZoomIn,
|
|
13
|
+
ZoomOut
|
|
14
|
+
} from '@lucide/svelte';
|
|
5
15
|
import { downloadFiles, isFileData, MIME_BY_EXT } from '../../utils/file-download';
|
|
16
|
+
import { getLocaleContext } from '../../i18n/localeContext.svelte';
|
|
17
|
+
import { formatMessage } from '../../i18n/messages';
|
|
6
18
|
|
|
7
19
|
interface Props {
|
|
8
20
|
item: OutputImageLayoutItem;
|
|
@@ -11,29 +23,42 @@
|
|
|
11
23
|
|
|
12
24
|
let { item, value }: Props = $props();
|
|
13
25
|
|
|
26
|
+
const locale = getLocaleContext();
|
|
27
|
+
const t = $derived(locale.messages);
|
|
28
|
+
|
|
14
29
|
const SUPPORTED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg']);
|
|
30
|
+
const ZOOM_STEP = 1.5;
|
|
31
|
+
const MAX_ZOOM_OVER_NATURAL = 32;
|
|
32
|
+
// Above this many screen pixels per image pixel, smoothing hides exactly the detail the user
|
|
33
|
+
// zoomed in to see, so raster images switch to nearest-neighbour.
|
|
34
|
+
const PIXELATED_FROM_SCALE = 2;
|
|
35
|
+
|
|
36
|
+
const extOf = (f: FileData) => (f.fileType ?? '').toLowerCase();
|
|
15
37
|
|
|
16
|
-
const
|
|
17
|
-
if (Array.isArray(value)) return value.
|
|
18
|
-
return isFileData(value) ? value :
|
|
38
|
+
const files = $derived.by<FileData[]>(() => {
|
|
39
|
+
if (Array.isArray(value)) return value.filter(isFileData);
|
|
40
|
+
return isFileData(value) ? [value] : [];
|
|
19
41
|
});
|
|
42
|
+
const images = $derived(files.filter((f) => SUPPORTED_EXTS.has(extOf(f))));
|
|
43
|
+
const unsupported = $derived(images.length === 0 ? files[0] : undefined);
|
|
20
44
|
|
|
21
|
-
|
|
22
|
-
const
|
|
45
|
+
let index = $state(0);
|
|
46
|
+
const currentIndex = $derived(Math.max(0, Math.min(index, images.length - 1)));
|
|
47
|
+
const current = $derived(images[currentIndex] ?? null);
|
|
48
|
+
const isSvg = $derived(current ? extOf(current) === '.svg' : false);
|
|
23
49
|
|
|
24
50
|
// Binary formats (PNG/JPG/WEBP/GIF) arrive base64; SVG arrives as plain UTF-8 XML
|
|
25
51
|
// and must be percent-encoded into the data URL.
|
|
26
52
|
// Rendering through <img src> rather than inlining the SVG is a security choice:
|
|
27
53
|
// browsers disable scripting in image-context SVGs, so untrusted SVG cannot
|
|
28
54
|
// execute against the host. Don't switch to {@html} or an inline <svg>.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (file.isBase64Encoded) {
|
|
33
|
-
return `data:${mime};base64,${file.data}`;
|
|
34
|
-
}
|
|
55
|
+
function toDataUrl(file: FileData): string {
|
|
56
|
+
const mime = MIME_BY_EXT[extOf(file)] ?? 'application/octet-stream';
|
|
57
|
+
if (file.isBase64Encoded) return `data:${mime};base64,${file.data}`;
|
|
35
58
|
return `data:${mime};utf8,${encodeURIComponent(file.data)}`;
|
|
36
|
-
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dataUrl = $derived(current ? toDataUrl(current) : null);
|
|
37
62
|
|
|
38
63
|
const cfg = $derived(item.config ?? {});
|
|
39
64
|
const allowDownload = $derived(cfg.allowDownload ?? true);
|
|
@@ -41,103 +66,437 @@
|
|
|
41
66
|
const backgroundColor = $derived(cfg.backgroundColor);
|
|
42
67
|
|
|
43
68
|
let wrapperEl = $state<HTMLDivElement | null>(null);
|
|
69
|
+
let stageEl = $state<HTMLDivElement | null>(null);
|
|
44
70
|
let isFullscreen = $state(false);
|
|
45
71
|
let downloading = $state(false);
|
|
46
72
|
let error = $state<string | null>(null);
|
|
73
|
+
let decodeFailed = $state(false);
|
|
74
|
+
|
|
75
|
+
// View state. `scale` is screen px per image px; (x, y) is the image's top-left in stage px.
|
|
76
|
+
// The image is sized with width/height rather than a CSS transform so the browser re-rasters
|
|
77
|
+
// SVGs and resamples bitmaps at the displayed size instead of stretching a cached layer.
|
|
78
|
+
let stageW = $state(0);
|
|
79
|
+
let stageH = $state(0);
|
|
80
|
+
let naturalW = $state(0);
|
|
81
|
+
let naturalH = $state(0);
|
|
82
|
+
let scale = $state(1);
|
|
83
|
+
let x = $state(0);
|
|
84
|
+
let y = $state(0);
|
|
85
|
+
let fitted = $state(true);
|
|
86
|
+
|
|
87
|
+
const ready = $derived(naturalW > 0 && naturalH > 0 && stageW > 0 && stageH > 0);
|
|
88
|
+
const fitScale = $derived(ready ? Math.min(stageW / naturalW, stageH / naturalH) : 1);
|
|
89
|
+
const minScale = $derived(Math.min(fitScale, 1));
|
|
90
|
+
const maxScale = $derived(Math.max(fitScale, 1) * MAX_ZOOM_OVER_NATURAL);
|
|
91
|
+
const zoomPercent = $derived(Math.round(scale * 100));
|
|
92
|
+
const pannable = $derived(naturalW * scale > stageW + 1 || naturalH * scale > stageH + 1);
|
|
93
|
+
const pixelated = $derived(!isSvg && scale >= PIXELATED_FROM_SCALE);
|
|
94
|
+
|
|
95
|
+
function clampAxis(pos: number, size: number, stage: number) {
|
|
96
|
+
if (size <= stage) return (stage - size) / 2;
|
|
97
|
+
return Math.min(0, Math.max(stage - size, pos));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function applyView(nextScale: number, nextX: number, nextY: number) {
|
|
101
|
+
scale = nextScale;
|
|
102
|
+
x = clampAxis(nextX, naturalW * nextScale, stageW);
|
|
103
|
+
y = clampAxis(nextY, naturalH * nextScale, stageH);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fit() {
|
|
107
|
+
fitted = true;
|
|
108
|
+
applyView(fitScale, 0, 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function zoomTo(target: number, px = stageW / 2, py = stageH / 2) {
|
|
112
|
+
const next = Math.min(maxScale, Math.max(minScale, target));
|
|
113
|
+
const ratio = next / scale;
|
|
114
|
+
fitted = Math.abs(next - fitScale) < 1e-3;
|
|
115
|
+
applyView(next, px - (px - x) * ratio, py - (py - y) * ratio);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Refit on stage resize (fullscreen, layout changes) unless the user has zoomed in.
|
|
119
|
+
$effect(() => {
|
|
120
|
+
if (!stageEl) return;
|
|
121
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
122
|
+
stageW = entry.contentRect.width;
|
|
123
|
+
stageH = entry.contentRect.height;
|
|
124
|
+
if (fitted) fit();
|
|
125
|
+
else applyView(Math.max(scale, minScale), x, y);
|
|
126
|
+
});
|
|
127
|
+
ro.observe(stageEl);
|
|
128
|
+
return () => ro.disconnect();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function onImageLoad(e: Event) {
|
|
132
|
+
const img = e.currentTarget as HTMLImageElement;
|
|
133
|
+
decodeFailed = false;
|
|
134
|
+
// A viewBox-only SVG can report 0×0; fall back to the browser's default replaced-element size.
|
|
135
|
+
const w = img.naturalWidth || 300;
|
|
136
|
+
const h = img.naturalHeight || 150;
|
|
137
|
+
// Keep the zoom across solves when the new image has the same size: the user is usually
|
|
138
|
+
// watching one detail change as they move a slider.
|
|
139
|
+
const sameSize = w === naturalW && h === naturalH;
|
|
140
|
+
naturalW = w;
|
|
141
|
+
naturalH = h;
|
|
142
|
+
if (!sameSize || fitted) fit();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- pointer: drag to pan, two fingers to pinch -------------------------------------------
|
|
146
|
+
// Not reactive state: only read inside handlers.
|
|
147
|
+
const pointers: Record<number, { x: number; y: number }> = {};
|
|
148
|
+
const pointerList = () => Object.values(pointers);
|
|
149
|
+
let dragging = $state(false);
|
|
150
|
+
let pinchDistance = 0;
|
|
151
|
+
|
|
152
|
+
function stagePoint(e: { clientX: number; clientY: number }) {
|
|
153
|
+
const r = stageEl!.getBoundingClientRect();
|
|
154
|
+
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onPointerDown(e: PointerEvent) {
|
|
158
|
+
if (e.button !== 0 || (e.target as Element).closest('button')) return;
|
|
159
|
+
stageEl?.setPointerCapture(e.pointerId);
|
|
160
|
+
pointers[e.pointerId] = stagePoint(e);
|
|
161
|
+
const count = pointerList().length;
|
|
162
|
+
dragging = count === 1 && pannable;
|
|
163
|
+
if (count === 2) {
|
|
164
|
+
const [a, b] = pointerList();
|
|
165
|
+
pinchDistance = Math.hypot(a.x - b.x, a.y - b.y);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onPointerMove(e: PointerEvent) {
|
|
170
|
+
const prev = pointers[e.pointerId];
|
|
171
|
+
if (!prev) return;
|
|
172
|
+
const p = stagePoint(e);
|
|
173
|
+
pointers[e.pointerId] = p;
|
|
174
|
+
|
|
175
|
+
if (pointerList().length === 2) {
|
|
176
|
+
const [a, b] = pointerList();
|
|
177
|
+
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
|
178
|
+
if (pinchDistance > 0)
|
|
179
|
+
zoomTo(scale * (distance / pinchDistance), (a.x + b.x) / 2, (a.y + b.y) / 2);
|
|
180
|
+
pinchDistance = distance;
|
|
181
|
+
} else if (dragging) {
|
|
182
|
+
fitted = false;
|
|
183
|
+
applyView(scale, x + p.x - prev.x, y + p.y - prev.y);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onPointerUp(e: PointerEvent) {
|
|
188
|
+
delete pointers[e.pointerId];
|
|
189
|
+
const count = pointerList().length;
|
|
190
|
+
if (count < 2) pinchDistance = 0;
|
|
191
|
+
if (count === 0) dragging = false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function onDoubleClick(e: MouseEvent) {
|
|
195
|
+
if (!fitted) return fit();
|
|
196
|
+
const p = stagePoint(e);
|
|
197
|
+
// Large images go to 1:1 (true pixels); small ones are already ≥1:1 when fitted, so magnify.
|
|
198
|
+
zoomTo(fitScale < 0.8 ? 1 : fitScale * 3, p.x, p.y);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- wheel: zoom on Ctrl/⌘ (and trackpad pinch, which sends ctrlKey) or in fullscreen -------
|
|
202
|
+
let showWheelHint = $state(false);
|
|
203
|
+
let wheelHintTimeout: ReturnType<typeof setTimeout>;
|
|
204
|
+
|
|
205
|
+
// Registered by hand: preventDefault needs a non-passive listener.
|
|
206
|
+
$effect(() => {
|
|
207
|
+
if (!stageEl) return;
|
|
208
|
+
const el = stageEl;
|
|
209
|
+
function onWheel(e: WheelEvent) {
|
|
210
|
+
if (!ready) return;
|
|
211
|
+
if (!(e.ctrlKey || e.metaKey || isFullscreen)) {
|
|
212
|
+
// Let the page scroll, but say how to zoom.
|
|
213
|
+
showWheelHint = true;
|
|
214
|
+
clearTimeout(wheelHintTimeout);
|
|
215
|
+
wheelHintTimeout = setTimeout(() => (showWheelHint = false), 1200);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
showWheelHint = false;
|
|
220
|
+
const delta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
|
|
221
|
+
const p = stagePoint(e);
|
|
222
|
+
zoomTo(scale * Math.exp(-delta * 0.002), p.x, p.y);
|
|
223
|
+
}
|
|
224
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
225
|
+
return () => {
|
|
226
|
+
el.removeEventListener('wheel', onWheel);
|
|
227
|
+
clearTimeout(wheelHintTimeout);
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
232
|
+
const actions: Record<string, () => void> = {
|
|
233
|
+
'+': () => zoomTo(scale * ZOOM_STEP),
|
|
234
|
+
'=': () => zoomTo(scale * ZOOM_STEP),
|
|
235
|
+
'-': () => zoomTo(scale / ZOOM_STEP),
|
|
236
|
+
'0': fit,
|
|
237
|
+
'1': () => zoomTo(1),
|
|
238
|
+
ArrowLeft: () => step(-1),
|
|
239
|
+
ArrowRight: () => step(1)
|
|
240
|
+
};
|
|
241
|
+
if (allowFullscreen) actions.f = toggleFullscreen;
|
|
242
|
+
const action = actions[e.key];
|
|
243
|
+
if (!action) return;
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
action();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function step(delta: number) {
|
|
249
|
+
if (images.length < 2) return;
|
|
250
|
+
select((currentIndex + delta + images.length) % images.length);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function select(i: number) {
|
|
254
|
+
if (i === currentIndex) return;
|
|
255
|
+
index = i;
|
|
256
|
+
// A different image in the set: start fitted rather than carrying over the old zoom.
|
|
257
|
+
naturalW = 0;
|
|
258
|
+
naturalH = 0;
|
|
259
|
+
fitted = true;
|
|
260
|
+
}
|
|
47
261
|
|
|
48
262
|
function toggleFullscreen() {
|
|
49
263
|
if (!wrapperEl) return;
|
|
50
264
|
if (!document.fullscreenElement) {
|
|
51
265
|
wrapperEl.requestFullscreen().catch((err) => {
|
|
52
|
-
error =
|
|
266
|
+
error = formatMessage(t.fullscreenUnavailable, { error: err.message });
|
|
53
267
|
});
|
|
54
268
|
} else {
|
|
55
269
|
document.exitFullscreen().catch((err) => {
|
|
56
|
-
error =
|
|
270
|
+
error = formatMessage(t.exitFullscreenFailed, { error: err.message });
|
|
57
271
|
});
|
|
58
272
|
}
|
|
59
273
|
}
|
|
60
274
|
|
|
61
275
|
$effect(() => {
|
|
62
276
|
function onFullscreenChange() {
|
|
63
|
-
isFullscreen =
|
|
277
|
+
isFullscreen = document.fullscreenElement === wrapperEl;
|
|
64
278
|
}
|
|
65
279
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
|
66
280
|
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
|
|
67
281
|
});
|
|
68
282
|
|
|
69
283
|
async function handleDownload() {
|
|
70
|
-
if (!
|
|
284
|
+
if (!current) return;
|
|
71
285
|
downloading = true;
|
|
72
286
|
error = null;
|
|
73
287
|
try {
|
|
74
|
-
await downloadFiles(
|
|
288
|
+
await downloadFiles(current, current.fileName);
|
|
75
289
|
} catch (e) {
|
|
76
|
-
error = e instanceof Error ? e.message :
|
|
290
|
+
error = e instanceof Error ? e.message : t.downloadFailed;
|
|
77
291
|
} finally {
|
|
78
292
|
downloading = false;
|
|
79
293
|
}
|
|
80
294
|
}
|
|
295
|
+
|
|
296
|
+
const toolButton =
|
|
297
|
+
'flex h-7 min-w-7 items-center justify-center rounded px-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40';
|
|
81
298
|
</script>
|
|
82
299
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<img
|
|
104
|
-
src={dataUrl}
|
|
105
|
-
alt={item.displayName ?? file.fileName}
|
|
106
|
-
class="h-full w-full object-contain"
|
|
107
|
-
/>
|
|
108
|
-
</div>
|
|
109
|
-
|
|
110
|
-
{#if allowDownload || allowFullscreen}
|
|
300
|
+
<!-- The layout hides this output until a solve returns a file, so there is no empty state here. -->
|
|
301
|
+
{#if error || unsupported || current}
|
|
302
|
+
<div
|
|
303
|
+
bind:this={wrapperEl}
|
|
304
|
+
class="rounded flex flex-col overflow-hidden border border-border bg-background {isFullscreen
|
|
305
|
+
? 'h-screen'
|
|
306
|
+
: ''}"
|
|
307
|
+
>
|
|
308
|
+
{#if error}
|
|
309
|
+
<div class="px-4 py-3 text-sm text-destructive">{error}</div>
|
|
310
|
+
{:else if unsupported}
|
|
311
|
+
<div class="px-4 py-3 text-sm text-destructive">
|
|
312
|
+
{formatMessage(t.imageUnsupported, {
|
|
313
|
+
ext: extOf(unsupported) || t.imageUnknownFormat,
|
|
314
|
+
formats: [...SUPPORTED_EXTS].join(', ')
|
|
315
|
+
})}
|
|
316
|
+
</div>
|
|
317
|
+
{:else if current}
|
|
318
|
+
<!-- The stage is a pan/zoom surface with its own keyboard shortcuts; no native element fits. -->
|
|
319
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
111
320
|
<div
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
321
|
+
bind:this={stageEl}
|
|
322
|
+
role="application"
|
|
323
|
+
aria-roledescription={t.imageViewer}
|
|
324
|
+
aria-label={item.displayName ?? current.fileName}
|
|
325
|
+
tabindex="0"
|
|
326
|
+
class="relative w-full overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring
|
|
327
|
+
{isFullscreen ? 'flex-1' : 'h-95'}
|
|
328
|
+
{dragging ? 'cursor-grabbing' : pannable ? 'cursor-grab' : 'cursor-zoom-in'}
|
|
329
|
+
{fitted ? 'touch-pan-y' : 'touch-none'}
|
|
330
|
+
{backgroundColor ? 'bg-(--image-bg)' : 'checkerboard'}"
|
|
331
|
+
style:--image-bg={backgroundColor}
|
|
332
|
+
onpointerdown={onPointerDown}
|
|
333
|
+
onpointermove={onPointerMove}
|
|
334
|
+
onpointerup={onPointerUp}
|
|
335
|
+
onpointercancel={onPointerUp}
|
|
336
|
+
ondblclick={onDoubleClick}
|
|
337
|
+
onkeydown={onKeyDown}
|
|
115
338
|
>
|
|
116
|
-
{#if
|
|
339
|
+
{#if decodeFailed}
|
|
340
|
+
<div class="inset-0 text-sm absolute flex items-center justify-center text-destructive">
|
|
341
|
+
{formatMessage(t.imageDecodeFailed, { file: `${current.fileName}${current.fileType}` })}
|
|
342
|
+
</div>
|
|
343
|
+
{:else}
|
|
344
|
+
{#if !ready}
|
|
345
|
+
<div
|
|
346
|
+
class="inset-0 gap-2 text-sm absolute flex items-center justify-center text-muted-foreground"
|
|
347
|
+
>
|
|
348
|
+
<LoaderCircle size={16} class="animate-spin" />
|
|
349
|
+
{t.imageLoading}
|
|
350
|
+
</div>
|
|
351
|
+
{/if}
|
|
352
|
+
<img
|
|
353
|
+
src={dataUrl}
|
|
354
|
+
alt=""
|
|
355
|
+
draggable="false"
|
|
356
|
+
onload={onImageLoad}
|
|
357
|
+
onerror={() => (decodeFailed = true)}
|
|
358
|
+
class="absolute top-(--img-y) left-(--img-x) h-(--img-h) w-(--img-w) max-w-none
|
|
359
|
+
{pixelated ? 'pixelated' : ''}
|
|
360
|
+
{ready ? '' : 'invisible'}"
|
|
361
|
+
style:--img-x="{x}px"
|
|
362
|
+
style:--img-y="{y}px"
|
|
363
|
+
style:--img-w="{naturalW * scale}px"
|
|
364
|
+
style:--img-h="{naturalH * scale}px"
|
|
365
|
+
/>
|
|
366
|
+
{/if}
|
|
367
|
+
|
|
368
|
+
{#if images.length > 1}
|
|
117
369
|
<button
|
|
118
|
-
onclick={
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
class="
|
|
370
|
+
onclick={() => step(-1)}
|
|
371
|
+
title="{t.previousImage} (←)"
|
|
372
|
+
aria-label={t.previousImage}
|
|
373
|
+
class="left-2 p-1.5 backdrop-blur-sm absolute top-1/2 -translate-y-1/2 rounded-full border border-border bg-background/80 text-foreground hover:bg-background"
|
|
122
374
|
>
|
|
123
|
-
<
|
|
375
|
+
<ChevronLeft size={16} />
|
|
376
|
+
</button>
|
|
377
|
+
<button
|
|
378
|
+
onclick={() => step(1)}
|
|
379
|
+
title="{t.nextImage} (→)"
|
|
380
|
+
aria-label={t.nextImage}
|
|
381
|
+
class="right-2 p-1.5 backdrop-blur-sm absolute top-1/2 -translate-y-1/2 rounded-full border border-border bg-background/80 text-foreground hover:bg-background"
|
|
382
|
+
>
|
|
383
|
+
<ChevronRight size={16} />
|
|
124
384
|
</button>
|
|
125
385
|
{/if}
|
|
126
386
|
|
|
127
|
-
|
|
387
|
+
<div
|
|
388
|
+
class="px-2.5 py-1 text-xs bottom-2 rounded pointer-events-none absolute left-1/2 -translate-x-1/2 bg-foreground/80 text-background transition-opacity {showWheelHint
|
|
389
|
+
? 'opacity-100'
|
|
390
|
+
: 'opacity-0'}"
|
|
391
|
+
>
|
|
392
|
+
{t.zoomHint}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
{#if images.length > 1}
|
|
397
|
+
<div class="gap-1.5 p-1.5 flex overflow-x-auto border-t border-border">
|
|
398
|
+
{#each images as image, i (i)}
|
|
399
|
+
<button
|
|
400
|
+
onclick={() => select(i)}
|
|
401
|
+
title="{image.fileName}{image.fileType}"
|
|
402
|
+
class="h-12 w-16 rounded shrink-0 overflow-hidden border-2 bg-muted transition-colors {i ===
|
|
403
|
+
currentIndex
|
|
404
|
+
? 'border-primary'
|
|
405
|
+
: 'border-transparent opacity-70 hover:opacity-100'}"
|
|
406
|
+
>
|
|
407
|
+
<img src={toDataUrl(image)} alt="" class="h-full w-full object-contain" />
|
|
408
|
+
</button>
|
|
409
|
+
{/each}
|
|
410
|
+
</div>
|
|
411
|
+
{/if}
|
|
412
|
+
|
|
413
|
+
<div class="gap-2 px-2 py-1 text-xs flex items-center border-t border-border">
|
|
414
|
+
<div class="min-w-0 gap-1.5 flex flex-1 items-center text-muted-foreground">
|
|
415
|
+
<span class="truncate text-foreground">{current.fileName}{current.fileType}</span>
|
|
416
|
+
{#if ready}
|
|
417
|
+
<span class="shrink-0 tabular-nums">{naturalW}×{naturalH}</span>
|
|
418
|
+
{/if}
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div class="gap-0.5 flex shrink-0 items-center">
|
|
128
422
|
<button
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
423
|
+
class={toolButton}
|
|
424
|
+
onclick={() => zoomTo(scale / ZOOM_STEP)}
|
|
425
|
+
disabled={!ready || scale <= minScale + 1e-3}
|
|
426
|
+
title="{t.zoomOut} (−)"
|
|
427
|
+
aria-label={t.zoomOut}
|
|
132
428
|
>
|
|
133
|
-
{
|
|
134
|
-
<Minimize size={14} />
|
|
135
|
-
{:else}
|
|
136
|
-
<Maximize size={14} />
|
|
137
|
-
{/if}
|
|
429
|
+
<ZoomOut size={14} />
|
|
138
430
|
</button>
|
|
139
|
-
|
|
431
|
+
<span class="w-11 text-center text-muted-foreground tabular-nums">{zoomPercent}%</span>
|
|
432
|
+
<button
|
|
433
|
+
class={toolButton}
|
|
434
|
+
onclick={() => zoomTo(scale * ZOOM_STEP)}
|
|
435
|
+
disabled={!ready || scale >= maxScale - 1e-3}
|
|
436
|
+
title="{t.zoomIn} (+)"
|
|
437
|
+
aria-label={t.zoomIn}
|
|
438
|
+
>
|
|
439
|
+
<ZoomIn size={14} />
|
|
440
|
+
</button>
|
|
441
|
+
<button
|
|
442
|
+
class={toolButton}
|
|
443
|
+
onclick={fit}
|
|
444
|
+
disabled={!ready || fitted}
|
|
445
|
+
title="{t.zoomFit} (0)"
|
|
446
|
+
aria-label={t.zoomFit}
|
|
447
|
+
>
|
|
448
|
+
<Scan size={14} />
|
|
449
|
+
</button>
|
|
450
|
+
<button
|
|
451
|
+
class="{toolButton} font-medium tabular-nums"
|
|
452
|
+
onclick={() => zoomTo(1)}
|
|
453
|
+
disabled={!ready || Math.abs(scale - 1) < 1e-3}
|
|
454
|
+
title="{t.zoomActual} (1)"
|
|
455
|
+
aria-label={t.zoomActual}
|
|
456
|
+
>
|
|
457
|
+
1:1
|
|
458
|
+
</button>
|
|
459
|
+
|
|
460
|
+
{#if allowDownload || allowFullscreen}
|
|
461
|
+
<div class="mx-1 h-4 w-px bg-border"></div>
|
|
462
|
+
{/if}
|
|
463
|
+
{#if allowDownload}
|
|
464
|
+
<button
|
|
465
|
+
class={toolButton}
|
|
466
|
+
onclick={handleDownload}
|
|
467
|
+
disabled={downloading}
|
|
468
|
+
title={t.downloadImage}
|
|
469
|
+
aria-label={t.downloadImage}
|
|
470
|
+
>
|
|
471
|
+
<Download size={14} />
|
|
472
|
+
</button>
|
|
473
|
+
{/if}
|
|
474
|
+
{#if allowFullscreen}
|
|
475
|
+
<button
|
|
476
|
+
class={toolButton}
|
|
477
|
+
onclick={toggleFullscreen}
|
|
478
|
+
title="{isFullscreen ? t.exitFullscreen : t.fullscreen} (F)"
|
|
479
|
+
aria-label={isFullscreen ? t.exitFullscreen : t.fullscreen}
|
|
480
|
+
>
|
|
481
|
+
{#if isFullscreen}
|
|
482
|
+
<Minimize size={14} />
|
|
483
|
+
{:else}
|
|
484
|
+
<Maximize size={14} />
|
|
485
|
+
{/if}
|
|
486
|
+
</button>
|
|
487
|
+
{/if}
|
|
488
|
+
</div>
|
|
140
489
|
</div>
|
|
141
490
|
{/if}
|
|
142
|
-
|
|
143
|
-
|
|
491
|
+
</div>
|
|
492
|
+
{/if}
|
|
493
|
+
|
|
494
|
+
<style>
|
|
495
|
+
/* Shows transparency in PNG/WebP/SVG output instead of blending it into the page. */
|
|
496
|
+
.checkerboard {
|
|
497
|
+
background: repeating-conic-gradient(var(--muted) 0 25%, transparent 0 50%) 0 0 / 16px 16px;
|
|
498
|
+
}
|
|
499
|
+
.pixelated {
|
|
500
|
+
image-rendering: pixelated;
|
|
501
|
+
}
|
|
502
|
+
</style>
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
TextInput
|
|
28
28
|
} from './inputs';
|
|
29
29
|
import { getClientSlot } from '../../contexts/clientSlotContext.svelte';
|
|
30
|
+
import { getLocaleContext } from '../../i18n/localeContext.svelte';
|
|
30
31
|
|
|
31
32
|
interface Props {
|
|
32
33
|
item: InputLayoutItem;
|
|
@@ -52,6 +53,8 @@
|
|
|
52
53
|
dynamicOptions
|
|
53
54
|
}: Props = $props();
|
|
54
55
|
|
|
56
|
+
const locale = getLocaleContext();
|
|
57
|
+
|
|
55
58
|
const inputId = $derived(`input-${item.paramId}`);
|
|
56
59
|
const label = $derived(displayName || item.displayName || item.paramId);
|
|
57
60
|
|
|
@@ -171,7 +174,11 @@
|
|
|
171
174
|
</script>
|
|
172
175
|
|
|
173
176
|
{#snippet labelText(forId?: string, tone: 'quiet' | 'strong' = 'quiet')}
|
|
174
|
-
<Field.Label
|
|
177
|
+
<Field.Label
|
|
178
|
+
for={forId}
|
|
179
|
+
id={forId ? `${forId}-label` : undefined}
|
|
180
|
+
class="min-w-0 block break-words"
|
|
181
|
+
>
|
|
175
182
|
<span
|
|
176
183
|
class={tone === 'quiet' ? 'font-normal text-muted-foreground' : 'font-normal text-foreground'}
|
|
177
184
|
>{label}</span
|
|
@@ -221,6 +228,7 @@
|
|
|
221
228
|
{@const config = item.config}
|
|
222
229
|
<NumberInput
|
|
223
230
|
{inputId}
|
|
231
|
+
labelId="{inputId}-label"
|
|
224
232
|
value={typeof value === 'number' ? value : undefined}
|
|
225
233
|
{config}
|
|
226
234
|
onChange={commit}
|
|
@@ -290,7 +298,7 @@
|
|
|
290
298
|
/>
|
|
291
299
|
{/if}
|
|
292
300
|
{:else}
|
|
293
|
-
<p class="text-sm text-muted-foreground">
|
|
301
|
+
<p class="text-sm text-muted-foreground">{locale.messages.noOptionsYet}</p>
|
|
294
302
|
{/if}
|
|
295
303
|
{:else if isFileWidget(item)}
|
|
296
304
|
{@const config = item.config as FileInputWidgetConfig}
|
|
@@ -303,6 +311,7 @@
|
|
|
303
311
|
/>
|
|
304
312
|
{:else if isColorWidget(item)}
|
|
305
313
|
<ColorInput
|
|
314
|
+
{inputId}
|
|
306
315
|
value={typeof value === 'string' ? value : '#000000'}
|
|
307
316
|
onChange={(newValue) => commit(newValue)}
|
|
308
317
|
/>
|