@poodle64/ui 2026.9.16 → 2026.9.18
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/README.md +124 -24
- package/dist/components/ui/app-shell/app-shell.svelte +2 -2
- package/dist/components/ui/app-shell/padding.d.ts +20 -0
- package/dist/components/ui/app-shell/padding.js +20 -0
- package/dist/components/ui/fact-grid/fact-grid.svelte +53 -0
- package/dist/components/ui/fact-grid/fact-grid.svelte.d.ts +18 -0
- package/dist/components/ui/fact-grid/index.d.ts +3 -0
- package/dist/components/ui/fact-grid/index.js +2 -0
- package/dist/components/ui/info-tip/info-tip.svelte +18 -1
- package/dist/components/ui/page-canvas/index.d.ts +3 -0
- package/dist/components/ui/page-canvas/index.js +2 -0
- package/dist/components/ui/page-canvas/page-canvas.svelte +374 -0
- package/dist/components/ui/page-canvas/page-canvas.svelte.d.ts +23 -0
- package/dist/components/ui/page-canvas/types.d.ts +35 -0
- package/dist/components/ui/page-canvas/types.js +8 -0
- package/dist/components/ui/panel/panel.svelte +97 -5
- package/dist/components/ui/panel/panel.svelte.d.ts +36 -1
- package/dist/components/ui/settings-shell/index.d.ts +2 -0
- package/dist/components/ui/settings-shell/index.js +2 -0
- package/dist/components/ui/settings-shell/settings-shell.svelte +111 -0
- package/dist/components/ui/settings-shell/settings-shell.svelte.d.ts +78 -0
- package/dist/styles.css +68 -13
- package/package.json +1 -1
- package/registry/component-map.json +40 -5
- package/registry/component-map.md +19 -2
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// One page at a time, fitted to its pane both ways so an A4 statement and a
|
|
3
|
+
// tall phone-scanned receipt both fill it — the shared engine behind
|
|
4
|
+
// godswood's run view and pebblestone's invoice/quote panes alike
|
|
5
|
+
// (radar-hooves/godswood#839). The package draws no page itself: a
|
|
6
|
+
// consumer supplies image URLs, a PDF canvas, or any other render through
|
|
7
|
+
// the `page` snippet, and reports each page's own intrinsic size once it
|
|
8
|
+
// is known (an image's `onload`, a PDF viewport) via `reportSize` — this
|
|
9
|
+
// component owns only the fit/zoom/pan/pager arithmetic and the evidence
|
|
10
|
+
// overlay drawn on top of it.
|
|
11
|
+
//
|
|
12
|
+
// A region is a box in PAGE FRACTIONS (0–1 each way), never a pixel tied
|
|
13
|
+
// to one raster's resolution, so the same shape works whatever the page's
|
|
14
|
+
// native size turns out to be. `activeRegionId` (hover, from a list the
|
|
15
|
+
// consumer renders beside the page) and `focusedRegionId` (click) both
|
|
16
|
+
// show the same solid outline; a click also zooms in on the region and
|
|
17
|
+
// centres it, leaving room for its label beside it where there is room
|
|
18
|
+
// and above it where there is not — the same room-check either way, since
|
|
19
|
+
// pinning only changes how much of the pane the page takes up.
|
|
20
|
+
// `lookedRegions` draws a further, non-interactive dashed set — "where it
|
|
21
|
+
// looked" — for the current page only.
|
|
22
|
+
import type { Snippet } from 'svelte';
|
|
23
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
24
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
25
|
+
import { Skeleton } from '../skeleton/index.js';
|
|
26
|
+
import { Button } from '../button/index.js';
|
|
27
|
+
import Plus from '@lucide/svelte/icons/plus';
|
|
28
|
+
import Minus from '@lucide/svelte/icons/minus';
|
|
29
|
+
import Maximize from '@lucide/svelte/icons/maximize';
|
|
30
|
+
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
|
31
|
+
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
32
|
+
import type { PageCanvasRegion, PageCanvasRenderInfo } from './types.js';
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
pageCount,
|
|
36
|
+
currentPage = $bindable(1),
|
|
37
|
+
page,
|
|
38
|
+
regions = [],
|
|
39
|
+
lookedRegions = [],
|
|
40
|
+
activeRegionId = $bindable(null),
|
|
41
|
+
focusedRegionId = $bindable(null),
|
|
42
|
+
zoom = $bindable(1),
|
|
43
|
+
class: klass = '',
|
|
44
|
+
ref = $bindable(null),
|
|
45
|
+
...restProps
|
|
46
|
+
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
47
|
+
pageCount: number;
|
|
48
|
+
/** 1-based; bindable so a caller can drive it from outside (a thumbnail rail). */
|
|
49
|
+
currentPage?: number;
|
|
50
|
+
/** Renders one page's content at the size this canvas computes for it. */
|
|
51
|
+
page: Snippet<[pageNumber: number, info: PageCanvasRenderInfo]>;
|
|
52
|
+
/** Every region across every page; only the current page's are drawn. */
|
|
53
|
+
regions?: PageCanvasRegion[];
|
|
54
|
+
/** The non-interactive dashed "where it looked" set for the current page. */
|
|
55
|
+
lookedRegions?: PageCanvasRegion[];
|
|
56
|
+
/** Hovered in a list beside the page. */
|
|
57
|
+
activeRegionId?: string | null;
|
|
58
|
+
/** Clicked; zooms in on and centres the region. */
|
|
59
|
+
focusedRegionId?: string | null;
|
|
60
|
+
zoom?: number;
|
|
61
|
+
} = $props();
|
|
62
|
+
|
|
63
|
+
// The stage's own reserved space: PAD_X across both edges, PAD_TOP above
|
|
64
|
+
// the page, PAD_BOTTOM below it — room for the control bar so it sits
|
|
65
|
+
// clear of the fitted page rather than over its corners. LABEL_RESERVE is
|
|
66
|
+
// how much room beside the page a region's label needs to sit in it
|
|
67
|
+
// rather than above it.
|
|
68
|
+
const PAD_X = 24;
|
|
69
|
+
const PAD_TOP = 12;
|
|
70
|
+
const PAD_BOTTOM = 64;
|
|
71
|
+
const LABEL_RESERVE = 300;
|
|
72
|
+
const ZOOM_MIN = 0.5;
|
|
73
|
+
const ZOOM_MAX = 3;
|
|
74
|
+
const ZOOM_STEP = 0.25;
|
|
75
|
+
// How far a focused region may zoom past the whole-page fit, and how much
|
|
76
|
+
// smaller than that a beside-the-page layout is still allowed to be
|
|
77
|
+
// before it gives up the side column and fills the pane's width instead.
|
|
78
|
+
const FOCUS_MAX_OVER_FIT = 3.4;
|
|
79
|
+
const FOCUS_MIN_FOR_SIDE_LABEL = 1.2;
|
|
80
|
+
|
|
81
|
+
let stageW = $state(0);
|
|
82
|
+
let stageH = $state(0);
|
|
83
|
+
let natural = $state<Record<number, { width: number; height: number }>>({});
|
|
84
|
+
|
|
85
|
+
function reportSize(pageNumber: number) {
|
|
86
|
+
return (width: number, height: number) => {
|
|
87
|
+
if (width <= 0 || height <= 0) return;
|
|
88
|
+
const known = natural[pageNumber];
|
|
89
|
+
if (known && known.width === width && known.height === height) return;
|
|
90
|
+
natural[pageNumber] = { width, height };
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function clampZoom(value: number): number {
|
|
95
|
+
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value * 100) / 100));
|
|
96
|
+
}
|
|
97
|
+
function zoomIn() {
|
|
98
|
+
zoom = clampZoom(zoom + ZOOM_STEP);
|
|
99
|
+
}
|
|
100
|
+
function zoomOut() {
|
|
101
|
+
zoom = clampZoom(zoom - ZOOM_STEP);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function goToPage(target: number) {
|
|
105
|
+
const next = Math.min(pageCount, Math.max(1, target));
|
|
106
|
+
if (next === currentPage) return;
|
|
107
|
+
currentPage = next;
|
|
108
|
+
zoom = 1;
|
|
109
|
+
focusedRegionId = null;
|
|
110
|
+
}
|
|
111
|
+
function prevPage() {
|
|
112
|
+
goToPage(currentPage - 1);
|
|
113
|
+
}
|
|
114
|
+
function nextPage() {
|
|
115
|
+
goToPage(currentPage + 1);
|
|
116
|
+
}
|
|
117
|
+
function backToWholePage() {
|
|
118
|
+
focusedRegionId = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Focusing a region on another page turns to that page — the approved
|
|
122
|
+
// canvas's own rule (`pg = pinned ? line.page : st.pg`). Hover does not:
|
|
123
|
+
// only a click is decisive enough to leave the page the caller is reading.
|
|
124
|
+
$effect(() => {
|
|
125
|
+
if (focusedRegionId === null) return;
|
|
126
|
+
const region = regions.find((r) => r.id === focusedRegionId);
|
|
127
|
+
if (region && region.page !== currentPage) currentPage = region.page;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
function handleWheel(event: WheelEvent) {
|
|
131
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
if (event.deltaY < 0) zoomIn();
|
|
134
|
+
else zoomOut();
|
|
135
|
+
}
|
|
136
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
137
|
+
if (event.key === 'ArrowLeft') {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
prevPage();
|
|
140
|
+
} else if (event.key === 'ArrowRight') {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
nextPage();
|
|
143
|
+
} else if (event.key === '+' || event.key === '=') {
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
zoomIn();
|
|
146
|
+
} else if (event.key === '-') {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
zoomOut();
|
|
149
|
+
} else if (event.key === '0') {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
zoom = 1;
|
|
152
|
+
} else if (event.key === 'Escape' && focusedRegionId !== null) {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
backToWholePage();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const size = $derived(natural[currentPage]);
|
|
159
|
+
const focusedRegion = $derived(
|
|
160
|
+
focusedRegionId
|
|
161
|
+
? (regions.find((r) => r.id === focusedRegionId && r.page === currentPage) ?? null)
|
|
162
|
+
: null
|
|
163
|
+
);
|
|
164
|
+
const shownRegionId = $derived(focusedRegionId ?? activeRegionId);
|
|
165
|
+
const shownRegion = $derived(
|
|
166
|
+
shownRegionId ? (regions.find((r) => r.id === shownRegionId && r.page === currentPage) ?? null) : null
|
|
167
|
+
);
|
|
168
|
+
const lookedOnPage = $derived(lookedRegions.filter((r) => r.page === currentPage));
|
|
169
|
+
|
|
170
|
+
// The whole-page fit, then a focused region's own zoom-and-centre, ported
|
|
171
|
+
// from the fat-controller run-view design canvas (godswood
|
|
172
|
+
// docs/design/fat-controller/project/Run-*.dc.html, `renderVals`). Past
|
|
173
|
+
// the fit, the frame outgrows the pane; `left`/`top` are floored at 0 so
|
|
174
|
+
// the page's own top-left corner is always the reachable start of the
|
|
175
|
+
// scrollable stage below rather than an offset an `overflow: auto` box
|
|
176
|
+
// can never scroll to (a negative position is outside its scroll range),
|
|
177
|
+
// and whatever spills past the pane's other edges scrolls into view there
|
|
178
|
+
// instead of the centring maths trying to pre-empt every edge itself.
|
|
179
|
+
const layout = $derived.by(() => {
|
|
180
|
+
const n = size;
|
|
181
|
+
if (!n || stageW <= PAD_X || stageH <= PAD_TOP + PAD_BOTTOM) return null;
|
|
182
|
+
const fit = Math.min((stageW - PAD_X) / n.width, (stageH - PAD_TOP - PAD_BOTTOM) / n.height);
|
|
183
|
+
let scale = fit * zoom;
|
|
184
|
+
let top = PAD_TOP;
|
|
185
|
+
let left = (stageW - n.width * scale) / 2;
|
|
186
|
+
let tagRight = stageW - n.width * scale >= LABEL_RESERVE;
|
|
187
|
+
if (focusedRegion) {
|
|
188
|
+
let s = Math.min(fit * FOCUS_MAX_OVER_FIT, (stageW - LABEL_RESERVE - PAD_X) / n.width);
|
|
189
|
+
tagRight = s >= fit * FOCUS_MIN_FOR_SIDE_LABEL;
|
|
190
|
+
if (!tagRight) s = Math.min(fit * FOCUS_MAX_OVER_FIT, (stageW - PAD_X) / n.width);
|
|
191
|
+
scale = Math.max(s, scale);
|
|
192
|
+
const cy = (focusedRegion.box.y + focusedRegion.box.h / 2) * n.height;
|
|
193
|
+
top = Math.min(
|
|
194
|
+
PAD_TOP,
|
|
195
|
+
Math.max(stageH - PAD_BOTTOM - n.height * scale, (stageH - PAD_BOTTOM) / 2 - cy * scale)
|
|
196
|
+
);
|
|
197
|
+
left = tagRight
|
|
198
|
+
? Math.max(PAD_X / 2, (stageW - LABEL_RESERVE - n.width * scale) / 2)
|
|
199
|
+
: (stageW - n.width * scale) / 2;
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
scale,
|
|
203
|
+
top: Math.max(0, top),
|
|
204
|
+
left: Math.max(0, left),
|
|
205
|
+
tagRight,
|
|
206
|
+
width: n.width * scale,
|
|
207
|
+
height: n.height * scale
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const renderInfo = $derived<PageCanvasRenderInfo>({
|
|
212
|
+
size: layout ? { width: layout.width, height: layout.height } : { width: 0, height: 0 },
|
|
213
|
+
reportSize: reportSize(currentPage)
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const frameStyle = $derived(
|
|
217
|
+
layout
|
|
218
|
+
? `position:absolute; left:${layout.left}px; top:${layout.top}px; width:${layout.width}px; height:${layout.height}px; transition: left 280ms cubic-bezier(0.2,0,0,1), top 280ms cubic-bezier(0.2,0,0,1), width 280ms cubic-bezier(0.2,0,0,1), height 280ms cubic-bezier(0.2,0,0,1);`
|
|
219
|
+
: `position:absolute; left:0; top:0; opacity:0; pointer-events:none;`
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
function regionBoxStyle(r: PageCanvasRegion): string {
|
|
223
|
+
const n = size!;
|
|
224
|
+
const l = layout!;
|
|
225
|
+
return `position:absolute; left:${r.box.x * n.width * l.scale}px; top:${r.box.y * n.height * l.scale}px; width:${r.box.w * n.width * l.scale}px; height:${r.box.h * n.height * l.scale}px;`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function toggleFocus(r: PageCanvasRegion) {
|
|
229
|
+
focusedRegionId = focusedRegionId === r.id ? null : r.id;
|
|
230
|
+
}
|
|
231
|
+
function onRegionKeydown(event: KeyboardEvent, r: PageCanvasRegion) {
|
|
232
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
toggleFocus(r);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
</script>
|
|
238
|
+
|
|
239
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
240
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
241
|
+
<div
|
|
242
|
+
bind:this={ref}
|
|
243
|
+
class={cn('relative outline-none', klass)}
|
|
244
|
+
bind:clientWidth={stageW}
|
|
245
|
+
bind:clientHeight={stageH}
|
|
246
|
+
onwheel={handleWheel}
|
|
247
|
+
onkeydown={handleKeydown}
|
|
248
|
+
tabindex="0"
|
|
249
|
+
role="document"
|
|
250
|
+
aria-label="Document page; use arrow keys to change page"
|
|
251
|
+
{...restProps}
|
|
252
|
+
>
|
|
253
|
+
{#if pageCount === 0}
|
|
254
|
+
<div class="text-muted-foreground flex h-full items-center justify-center text-sm">
|
|
255
|
+
No pages available
|
|
256
|
+
</div>
|
|
257
|
+
{:else}
|
|
258
|
+
<!--
|
|
259
|
+
The controls below are siblings of this scroller, not children of it,
|
|
260
|
+
so a page zoomed or focused past the pane scrolls under them rather
|
|
261
|
+
than carrying them away with it.
|
|
262
|
+
-->
|
|
263
|
+
<div class="absolute inset-0 overflow-auto">
|
|
264
|
+
<div style={frameStyle}>
|
|
265
|
+
{#if !size}
|
|
266
|
+
<Skeleton class="absolute inset-0" />
|
|
267
|
+
{/if}
|
|
268
|
+
{@render page(currentPage, renderInfo)}
|
|
269
|
+
|
|
270
|
+
{#if layout}
|
|
271
|
+
{#each lookedOnPage as region (region.id)}
|
|
272
|
+
<div
|
|
273
|
+
class="border-muted-foreground/70 bg-transparent pointer-events-none absolute rounded-sm border-2 border-dashed"
|
|
274
|
+
style={regionBoxStyle(region)}
|
|
275
|
+
aria-hidden="true"
|
|
276
|
+
>
|
|
277
|
+
{#if region.label}
|
|
278
|
+
<span
|
|
279
|
+
class="bg-card border-border text-muted-foreground absolute rounded-md border px-2 py-0.5 text-xs whitespace-nowrap"
|
|
280
|
+
style={layout.tagRight
|
|
281
|
+
? `left:${layout.width * (1 - region.box.x) + 10}px; top:0;`
|
|
282
|
+
: 'left:4px; top:4px;'}
|
|
283
|
+
>
|
|
284
|
+
{region.label}
|
|
285
|
+
</span>
|
|
286
|
+
{/if}
|
|
287
|
+
</div>
|
|
288
|
+
{/each}
|
|
289
|
+
|
|
290
|
+
{#if shownRegion}
|
|
291
|
+
<div style={regionBoxStyle(shownRegion)} class="absolute">
|
|
292
|
+
<span
|
|
293
|
+
role="button"
|
|
294
|
+
tabindex="0"
|
|
295
|
+
class="border-primary bg-primary/10 absolute inset-0 cursor-pointer rounded-sm border-2"
|
|
296
|
+
style="transform: rotate({shownRegion.rotateDeg ?? 0}deg); transition: all 160ms ease;"
|
|
297
|
+
onclick={() => toggleFocus(shownRegion)}
|
|
298
|
+
onkeydown={(e) => onRegionKeydown(e, shownRegion)}
|
|
299
|
+
aria-label={shownRegion.label
|
|
300
|
+
? `Focus the outlined region: ${shownRegion.label}`
|
|
301
|
+
: 'Focus the outlined region'}
|
|
302
|
+
></span>
|
|
303
|
+
{#if shownRegion.label}
|
|
304
|
+
<span
|
|
305
|
+
class="bg-card border-border text-foreground absolute rounded-md border px-2.5 py-1 text-xs font-medium whitespace-nowrap"
|
|
306
|
+
style={layout.tagRight
|
|
307
|
+
? 'left: calc(100% + 12px); top: 50%; transform: translateY(-50%);'
|
|
308
|
+
: 'left: 0; bottom: calc(100% + 8px);'}
|
|
309
|
+
>
|
|
310
|
+
{shownRegion.label}
|
|
311
|
+
</span>
|
|
312
|
+
{/if}
|
|
313
|
+
</div>
|
|
314
|
+
{/if}
|
|
315
|
+
{/if}
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div class="absolute bottom-3 left-3 flex items-center gap-2">
|
|
320
|
+
{#if focusedRegion}
|
|
321
|
+
<Button variant="outline" size="sm" onclick={backToWholePage}>Whole page</Button>
|
|
322
|
+
{:else if pageCount > 1}
|
|
323
|
+
<div class="bg-background flex items-center gap-0.5 rounded-lg p-1 shadow-sm">
|
|
324
|
+
<Button
|
|
325
|
+
variant="outline"
|
|
326
|
+
size="icon"
|
|
327
|
+
class="size-8"
|
|
328
|
+
onclick={prevPage}
|
|
329
|
+
disabled={currentPage <= 1}
|
|
330
|
+
aria-label="Previous page"
|
|
331
|
+
>
|
|
332
|
+
<ChevronLeft class="size-4" />
|
|
333
|
+
</Button>
|
|
334
|
+
<span class="text-foreground min-w-16 text-center text-xs font-medium tabular-nums">
|
|
335
|
+
{currentPage} / {pageCount}
|
|
336
|
+
</span>
|
|
337
|
+
<Button
|
|
338
|
+
variant="outline"
|
|
339
|
+
size="icon"
|
|
340
|
+
class="size-8"
|
|
341
|
+
onclick={nextPage}
|
|
342
|
+
disabled={currentPage >= pageCount}
|
|
343
|
+
aria-label="Next page"
|
|
344
|
+
>
|
|
345
|
+
<ChevronRight class="size-4" />
|
|
346
|
+
</Button>
|
|
347
|
+
</div>
|
|
348
|
+
{/if}
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<div
|
|
352
|
+
class="bg-background absolute right-3 bottom-3 flex items-center gap-0.5 rounded-lg p-1 shadow-sm"
|
|
353
|
+
>
|
|
354
|
+
<Button variant="outline" size="icon" class="size-8" onclick={zoomOut} aria-label="Zoom out">
|
|
355
|
+
<Minus class="size-4" />
|
|
356
|
+
</Button>
|
|
357
|
+
<span class="text-foreground min-w-11 text-center text-xs font-medium tabular-nums">
|
|
358
|
+
{Math.round(zoom * 100)}%
|
|
359
|
+
</span>
|
|
360
|
+
<Button variant="outline" size="icon" class="size-8" onclick={zoomIn} aria-label="Zoom in">
|
|
361
|
+
<Plus class="size-4" />
|
|
362
|
+
</Button>
|
|
363
|
+
<Button
|
|
364
|
+
variant="outline"
|
|
365
|
+
size="icon"
|
|
366
|
+
class="size-8"
|
|
367
|
+
onclick={() => (zoom = 1)}
|
|
368
|
+
aria-label="Fit the page"
|
|
369
|
+
>
|
|
370
|
+
<Maximize class="size-3.5" />
|
|
371
|
+
</Button>
|
|
372
|
+
</div>
|
|
373
|
+
{/if}
|
|
374
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { type WithElementRef } from '../../../utils.js';
|
|
4
|
+
import type { PageCanvasRegion, PageCanvasRenderInfo } from './types.js';
|
|
5
|
+
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
6
|
+
pageCount: number;
|
|
7
|
+
/** 1-based; bindable so a caller can drive it from outside (a thumbnail rail). */
|
|
8
|
+
currentPage?: number;
|
|
9
|
+
/** Renders one page's content at the size this canvas computes for it. */
|
|
10
|
+
page: Snippet<[pageNumber: number, info: PageCanvasRenderInfo]>;
|
|
11
|
+
/** Every region across every page; only the current page's are drawn. */
|
|
12
|
+
regions?: PageCanvasRegion[];
|
|
13
|
+
/** The non-interactive dashed "where it looked" set for the current page. */
|
|
14
|
+
lookedRegions?: PageCanvasRegion[];
|
|
15
|
+
/** Hovered in a list beside the page. */
|
|
16
|
+
activeRegionId?: string | null;
|
|
17
|
+
/** Clicked; zooms in on and centres the region. */
|
|
18
|
+
focusedRegionId?: string | null;
|
|
19
|
+
zoom?: number;
|
|
20
|
+
};
|
|
21
|
+
declare const PageCanvas: import("svelte").Component<$$ComponentProps, {}, "ref" | "currentPage" | "activeRegionId" | "focusedRegionId" | "zoom">;
|
|
22
|
+
type PageCanvas = ReturnType<typeof PageCanvas>;
|
|
23
|
+
export default PageCanvas;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared evidence vocabulary — plain shapes a consuming app maps a
|
|
3
|
+
* machine reading's own coordinates into. This package holds no image
|
|
4
|
+
* decoder, no PDF renderer and no OCR geometry; a region's box is a fraction
|
|
5
|
+
* of the page (0–1 each way), never a pixel tied to one raster's resolution,
|
|
6
|
+
* so the same shape describes an A4 page and a phone-scanned receipt alike.
|
|
7
|
+
*/
|
|
8
|
+
/** One outlined place on a page — evidence for something the app read there. */
|
|
9
|
+
export interface PageCanvasRegion {
|
|
10
|
+
/** Stable identity: bind `activeRegionId` / `focusedRegionId` to this. */
|
|
11
|
+
id: string;
|
|
12
|
+
/** 1-based page number this region belongs to. */
|
|
13
|
+
page: number;
|
|
14
|
+
/** The region's box, in fractions (0–1) of the page's own width/height. */
|
|
15
|
+
box: {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
w: number;
|
|
19
|
+
h: number;
|
|
20
|
+
};
|
|
21
|
+
/** A few degrees of tilt, for a phone-scanned page that slopes. */
|
|
22
|
+
rotateDeg?: number;
|
|
23
|
+
/** Shown beside the outlined region where there is room, above it where there is not. */
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
26
|
+
/** What the canvas hands the `page` snippet for one render. */
|
|
27
|
+
export interface PageCanvasRenderInfo {
|
|
28
|
+
/** The box to render at, in CSS pixels; {0, 0} until the page's own size is known. */
|
|
29
|
+
size: {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
};
|
|
33
|
+
/** Call once the page's intrinsic size is known (an image's onload, a PDF viewport). */
|
|
34
|
+
reportSize: (width: number, height: number) => void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared evidence vocabulary — plain shapes a consuming app maps a
|
|
3
|
+
* machine reading's own coordinates into. This package holds no image
|
|
4
|
+
* decoder, no PDF renderer and no OCR geometry; a region's box is a fraction
|
|
5
|
+
* of the page (0–1 each way), never a pixel tied to one raster's resolution,
|
|
6
|
+
* so the same shape describes an A4 page and a phone-scanned receipt alike.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet, Component } from 'svelte';
|
|
3
3
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
-
import type
|
|
4
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
5
5
|
|
|
6
6
|
// The generic titled card. Where DetailPanel is the *entity* surface (and owns
|
|
7
7
|
// a status and a close affordance), Panel is the plain sectioning card any
|
|
8
8
|
// route reaches for: an optional header with icon, subtitle and trailing
|
|
9
9
|
// actions, over a body that can opt out of padding for a flush table.
|
|
10
|
+
//
|
|
11
|
+
// It is also the settings SECTION, and there is no second component for that.
|
|
12
|
+
// A `SettingsSection` would have been this card with a different name — same
|
|
13
|
+
// surface, same header, same title — and the estate would then hold two
|
|
14
|
+
// answers to "a titled section of a route", which is the drift this package
|
|
15
|
+
// exists to end. What settings actually needed that this did not have is three
|
|
16
|
+
// props, and all three serve any route: a description that wraps, a footer for
|
|
17
|
+
// the Save/Cancel pair, and a destructive tone for a danger zone.
|
|
10
18
|
let {
|
|
11
19
|
title,
|
|
12
20
|
subtitle,
|
|
21
|
+
description,
|
|
13
22
|
icon: Icon,
|
|
14
23
|
action,
|
|
24
|
+
footer,
|
|
25
|
+
tone = 'default',
|
|
15
26
|
children,
|
|
16
27
|
pad = true,
|
|
17
28
|
class: klass = '',
|
|
@@ -19,29 +30,94 @@
|
|
|
19
30
|
...restProps
|
|
20
31
|
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
|
21
32
|
title?: string;
|
|
33
|
+
/**
|
|
34
|
+
* A one-line qualifier beside the title. It TRUNCATES, and every existing
|
|
35
|
+
* call site is written against that: a panel header is one rhythm, and a
|
|
36
|
+
* subtitle that wrapped would change it everywhere at once.
|
|
37
|
+
*
|
|
38
|
+
* For the sentence or two that explains what the section is for, use
|
|
39
|
+
* `description`, which wraps. Carry one or the other, not both.
|
|
40
|
+
*/
|
|
22
41
|
subtitle?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The sentence or two under the title, wrapped, explaining what this
|
|
44
|
+
* section does or what changing it costs.
|
|
45
|
+
*
|
|
46
|
+
* PageHeader deliberately has no equivalent — a page's long explanation
|
|
47
|
+
* goes in its `info` tip so no route carries a standing explainer banner —
|
|
48
|
+
* and that position does not carry down to here. A settings section's
|
|
49
|
+
* description IS the setting's meaning ("who can approve a bill, and up to
|
|
50
|
+
* what value"), and hiding it behind an (i) on the one destination whose
|
|
51
|
+
* whole job is explaining consequential toggles is worse than the banner
|
|
52
|
+
* that rule was written against. GitHub, Linear, Vercel and Stripe all
|
|
53
|
+
* wrap a description under the section title.
|
|
54
|
+
*/
|
|
55
|
+
description?: string;
|
|
23
56
|
icon?: Component<{ class?: string }>;
|
|
24
57
|
action?: Snippet;
|
|
58
|
+
/**
|
|
59
|
+
* The section's actions — a Save/Cancel pair — on a top-bordered muted
|
|
60
|
+
* strip at the foot, right-aligned. Same treatment as CardFooter's, so a
|
|
61
|
+
* panel's foot and a card's foot are the same foot.
|
|
62
|
+
*/
|
|
63
|
+
footer?: Snippet;
|
|
64
|
+
/**
|
|
65
|
+
* `destructive` is the danger zone: a section whose controls delete or
|
|
66
|
+
* revoke something. It moves the rule and the title to the destructive
|
|
67
|
+
* ink — the treatment Alert and Button already use for it — and nothing
|
|
68
|
+
* else, so the section is marked without being shouted.
|
|
69
|
+
*/
|
|
70
|
+
tone?: 'default' | 'destructive';
|
|
25
71
|
children: Snippet;
|
|
26
72
|
pad?: boolean;
|
|
27
73
|
} = $props();
|
|
74
|
+
|
|
75
|
+
const destructive = $derived(tone === 'destructive');
|
|
28
76
|
</script>
|
|
29
77
|
|
|
30
78
|
<section
|
|
31
79
|
bind:this={ref}
|
|
32
|
-
|
|
80
|
+
data-tone={tone === 'default' ? undefined : tone}
|
|
81
|
+
class={cn(
|
|
82
|
+
'bg-card ds-edge flex flex-col overflow-hidden rounded-lg border',
|
|
83
|
+
destructive ? 'border-destructive/40' : 'border-border',
|
|
84
|
+
klass
|
|
85
|
+
)}
|
|
33
86
|
{...restProps}
|
|
34
87
|
>
|
|
35
88
|
{#if title}
|
|
36
|
-
|
|
89
|
+
<!--
|
|
90
|
+
`items-start` once a description is present: a wrapping description makes
|
|
91
|
+
the title block taller than the action row, and centring the actions
|
|
92
|
+
against three lines of prose floats them in the middle of the header.
|
|
93
|
+
-->
|
|
94
|
+
<header
|
|
95
|
+
class={cn(
|
|
96
|
+
'flex gap-2.5 border-b px-4 py-3',
|
|
97
|
+
description ? 'items-start' : 'items-center',
|
|
98
|
+
destructive ? 'border-destructive/25' : 'border-border'
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
37
101
|
{#if Icon}
|
|
38
|
-
<Icon
|
|
102
|
+
<Icon
|
|
103
|
+
class={cn('size-4', destructive ? 'text-destructive' : 'text-muted-foreground')}
|
|
104
|
+
/>
|
|
39
105
|
{/if}
|
|
40
106
|
<div class="min-w-0">
|
|
41
|
-
<h2
|
|
107
|
+
<h2
|
|
108
|
+
class={cn(
|
|
109
|
+
'text-body leading-tight font-semibold tracking-tight',
|
|
110
|
+
destructive && 'text-destructive'
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
{title}
|
|
114
|
+
</h2>
|
|
42
115
|
{#if subtitle}
|
|
43
116
|
<p class="text-muted-foreground truncate text-xs">{subtitle}</p>
|
|
44
117
|
{/if}
|
|
118
|
+
{#if description}
|
|
119
|
+
<p class="text-muted-foreground mt-1 text-sm">{description}</p>
|
|
120
|
+
{/if}
|
|
45
121
|
</div>
|
|
46
122
|
{#if action}
|
|
47
123
|
<div class="ml-auto flex items-center gap-1.5">{@render action()}</div>
|
|
@@ -51,4 +127,20 @@
|
|
|
51
127
|
<div class={pad ? 'p-4' : ''}>
|
|
52
128
|
{@render children()}
|
|
53
129
|
</div>
|
|
130
|
+
{#if footer}
|
|
131
|
+
<!--
|
|
132
|
+
`mt-auto` so a footer sits at the bottom of a panel that has been given a
|
|
133
|
+
height (a fixed-height column, a grid row) rather than floating under
|
|
134
|
+
short content.
|
|
135
|
+
-->
|
|
136
|
+
<div
|
|
137
|
+
class={cn(
|
|
138
|
+
'bg-muted/50 mt-auto flex items-center justify-end gap-2 border-t px-4 py-3',
|
|
139
|
+
destructive ? 'border-destructive/25' : 'border-border'
|
|
140
|
+
)}
|
|
141
|
+
data-slot="panel-footer"
|
|
142
|
+
>
|
|
143
|
+
{@render footer()}
|
|
144
|
+
</div>
|
|
145
|
+
{/if}
|
|
54
146
|
</section>
|
|
@@ -1,13 +1,48 @@
|
|
|
1
1
|
import type { Snippet, Component } from 'svelte';
|
|
2
2
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
-
import type
|
|
3
|
+
import { type WithElementRef } from '../../../utils.js';
|
|
4
4
|
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
|
|
5
5
|
title?: string;
|
|
6
|
+
/**
|
|
7
|
+
* A one-line qualifier beside the title. It TRUNCATES, and every existing
|
|
8
|
+
* call site is written against that: a panel header is one rhythm, and a
|
|
9
|
+
* subtitle that wrapped would change it everywhere at once.
|
|
10
|
+
*
|
|
11
|
+
* For the sentence or two that explains what the section is for, use
|
|
12
|
+
* `description`, which wraps. Carry one or the other, not both.
|
|
13
|
+
*/
|
|
6
14
|
subtitle?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The sentence or two under the title, wrapped, explaining what this
|
|
17
|
+
* section does or what changing it costs.
|
|
18
|
+
*
|
|
19
|
+
* PageHeader deliberately has no equivalent — a page's long explanation
|
|
20
|
+
* goes in its `info` tip so no route carries a standing explainer banner —
|
|
21
|
+
* and that position does not carry down to here. A settings section's
|
|
22
|
+
* description IS the setting's meaning ("who can approve a bill, and up to
|
|
23
|
+
* what value"), and hiding it behind an (i) on the one destination whose
|
|
24
|
+
* whole job is explaining consequential toggles is worse than the banner
|
|
25
|
+
* that rule was written against. GitHub, Linear, Vercel and Stripe all
|
|
26
|
+
* wrap a description under the section title.
|
|
27
|
+
*/
|
|
28
|
+
description?: string;
|
|
7
29
|
icon?: Component<{
|
|
8
30
|
class?: string;
|
|
9
31
|
}>;
|
|
10
32
|
action?: Snippet;
|
|
33
|
+
/**
|
|
34
|
+
* The section's actions — a Save/Cancel pair — on a top-bordered muted
|
|
35
|
+
* strip at the foot, right-aligned. Same treatment as CardFooter's, so a
|
|
36
|
+
* panel's foot and a card's foot are the same foot.
|
|
37
|
+
*/
|
|
38
|
+
footer?: Snippet;
|
|
39
|
+
/**
|
|
40
|
+
* `destructive` is the danger zone: a section whose controls delete or
|
|
41
|
+
* revoke something. It moves the rule and the title to the destructive
|
|
42
|
+
* ink — the treatment Alert and Button already use for it — and nothing
|
|
43
|
+
* else, so the section is marked without being shouted.
|
|
44
|
+
*/
|
|
45
|
+
tone?: 'default' | 'destructive';
|
|
11
46
|
children: Snippet;
|
|
12
47
|
pad?: boolean;
|
|
13
48
|
};
|