@leavepulse/ui 0.28.1 → 0.28.3

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.
@@ -0,0 +1,515 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Straighten-and-crop for a picture on its way to being uploaded.
4
+ *
5
+ * Not a photo editor. The three things a camera gets wrong on the way into a
6
+ * record are the only things here: the phone was held sideways, the label is
7
+ * a third of the frame, and the shot is off by a couple of degrees. Anything
8
+ * beyond that belongs in a program made for it.
9
+ *
10
+ * The result comes back as a Blob, so the caller uploads what is on screen
11
+ * rather than the original plus a list of edits nothing downstream can apply.
12
+ * `null` from `export()` means "unchanged" — an untouched picture is passed
13
+ * through byte-for-byte instead of being re-encoded a generation worse.
14
+ *
15
+ * Zoom and pan are useZoomPan, the same maths the lightbox uses: the crop box
16
+ * is fixed and the picture moves under it, which is the interaction people
17
+ * already know from every avatar cropper.
18
+ */
19
+ import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue"
20
+ import { useHotkeys } from "../composables/useHotkeys"
21
+ import { useReveal } from "../composables/useReveal"
22
+ import { useZoomPan } from "../composables/useZoomPan"
23
+ import LpButton from "./LpButton.vue"
24
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
25
+ import LpIcon from "./LpIcon.vue"
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ /** The picture to work on. */
30
+ file: File | Blob
31
+ /**
32
+ * Shape of the crop box.
33
+ *
34
+ * The named ones are shorthand for the common cases; a NUMBER is the real
35
+ * interface — `:aspect="2.35"` for anamorphic, `:aspect="0.8"` for a
36
+ * shelf label — so a consumer whose shape is not on the list is not stuck
37
+ * choosing the nearest wrong one. "free" keeps the source's own
38
+ * proportions and crops only what panning and zoom leave outside.
39
+ */
40
+ aspect?: "free" | "square" | "4:3" | "3:4" | "16:9" | number
41
+ /** Longest edge of the exported image. Larger sources are scaled down. */
42
+ maxEdge?: number
43
+ /** JPEG quality of the export. */
44
+ quality?: number
45
+ /**
46
+ * Labels for the toolbar. Defaulted in English rather than left blank: an
47
+ * icon button with no accessible name is announced as "button", and the
48
+ * kit cannot reach the consumer's translations. Apps with i18n pass their
49
+ * own strings.
50
+ */
51
+ labels?: Partial<
52
+ Record<"rotateLeft" | "rotateRight" | "zoomIn" | "zoomOut" | "reset" | "canvas", string>
53
+ >
54
+ /** Fade the picture in when the editor first comes into view. */
55
+ animate?: boolean
56
+ }>(),
57
+ { aspect: "free", maxEdge: 2400, quality: 0.9, labels: () => ({}), animate: true },
58
+ )
59
+
60
+ // Same rule as the rest of the kit: the entrance belongs to arrival, not to
61
+ // mount. An editor inside a dialog that opens a minute later still draws.
62
+ const { el: revealAnchor, revealed } = useReveal({ immediate: () => !props.animate })
63
+
64
+ const text = computed(() => ({
65
+ rotateLeft: "Rotate left",
66
+ rotateRight: "Rotate right",
67
+ zoomIn: "Zoom in",
68
+ zoomOut: "Zoom out",
69
+ reset: "Reset",
70
+ canvas: "Crop area — arrows pan, +/− zoom, R rotates, Alt+wheel turns freely",
71
+ ...props.labels,
72
+ }))
73
+
74
+ const ASPECTS: Record<string, number | null> = {
75
+ free: null,
76
+ square: 1,
77
+ "4:3": 4 / 3,
78
+ "3:4": 3 / 4,
79
+ "16:9": 16 / 9,
80
+ }
81
+
82
+ const source = shallowRef<ImageBitmap | HTMLImageElement | null>(null)
83
+ const objectUrl = ref("")
84
+ const rotation = ref(0)
85
+ const viewport = ref<HTMLElement | null>(null)
86
+ const zoom = useZoomPan({ min: 1, max: 6 })
87
+
88
+ watch(viewport, (el) => zoom.setViewport(el))
89
+
90
+ /** Which way up it ends, 0-270, whatever the accumulated angle. */
91
+ const upright = computed(() => ((rotation.value % 360) + 360) % 360)
92
+
93
+ /** Touched at all? An untouched picture is uploaded as it arrived — four turns
94
+ * back to where it started is not an edit, however far the dial travelled. */
95
+ const edited = computed(() => upright.value !== 0 || zoom.scale.value !== 1)
96
+
97
+ watch(
98
+ () => props.file,
99
+ async (file) => {
100
+ revoke()
101
+ rotation.value = 0
102
+ zoom.reset()
103
+ objectUrl.value = URL.createObjectURL(file)
104
+ source.value = await decode(file, objectUrl.value)
105
+ },
106
+ { immediate: true },
107
+ )
108
+
109
+ /**
110
+ * The picture, as something canvas can draw and measure.
111
+ *
112
+ * `createImageBitmap` first: it decodes off the main thread and hands back
113
+ * real dimensions. It refuses SVG outright — "the source image could not be
114
+ * decoded" — which is not a corner case here, since a screenshot tool or a
115
+ * diagram export is exactly the sort of thing someone pastes in. So an <img>
116
+ * is the fallback, and it decodes everything the browser can render.
117
+ *
118
+ * Without this the editor still SHOWED the picture — the <img> tag never
119
+ * needed the bitmap — while silently measuring nothing: the frame fell back to
120
+ * a square, and export returned null. A failure that looks like a layout
121
+ * choice is the worst kind.
122
+ */
123
+ async function decode(
124
+ file: File | Blob,
125
+ url: string,
126
+ ): Promise<ImageBitmap | HTMLImageElement | null> {
127
+ try {
128
+ return await createImageBitmap(file)
129
+ } catch {
130
+ return await new Promise<HTMLImageElement | null>((resolve) => {
131
+ const img = new Image()
132
+ img.onload = () => resolve(img)
133
+ img.onerror = () => resolve(null)
134
+ img.src = url
135
+ })
136
+ }
137
+ }
138
+
139
+ /** Natural size, whichever kind of source came back. */
140
+ const sourceSize = computed(() => {
141
+ const s = source.value
142
+ if (!s) return null
143
+ return s instanceof HTMLImageElement
144
+ ? { width: s.naturalWidth, height: s.naturalHeight }
145
+ : { width: s.width, height: s.height }
146
+ })
147
+
148
+ function revoke() {
149
+ if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
150
+ objectUrl.value = ""
151
+ if (source.value instanceof ImageBitmap) source.value.close()
152
+ source.value = null
153
+ }
154
+
155
+ onBeforeUnmount(revoke)
156
+
157
+ function rotate(by: number) {
158
+ // Accumulated, never wrapped to 0-359. Normalising made the fourth quarter
159
+ // turn read as 270 → 0, and CSS animates that as three quarters BACKWARD:
160
+ // the picture unwound past every angle it had just come through instead of
161
+ // completing the turn. Everything downstream takes a modulus of its own.
162
+ rotation.value += by
163
+ // The pan turns with the picture instead of being thrown away. Resetting was
164
+ // the easy answer and the wrong one: someone zooms in on a rating plate,
165
+ // turns the photograph upright, and has to find the plate again. A quarter
166
+ // turn maps (x, y) to (-y, x) — the same rotation, applied to where they
167
+ // were looking.
168
+ const { x, y } = zoom.offset.value
169
+ const quarters = ((by / 90) % 4 + 4) % 4
170
+ let next = { x, y }
171
+ for (let i = 0; i < quarters; i++) next = { x: -next.y, y: next.x }
172
+ zoom.setView(next)
173
+ }
174
+
175
+ function reset() {
176
+ rotation.value = 0
177
+ zoom.reset()
178
+ }
179
+
180
+ /*
181
+ * The frame is FIXED and the picture moves under it.
182
+ *
183
+ * The other model — a rectangle dragged around a still image by its handles —
184
+ * was tried here first and it was the wrong one twice over. It gave two ways
185
+ * to say the same thing (zoom the picture, or resize the frame), which is the
186
+ * duplication this component is otherwise careful to avoid; and it put eight
187
+ * bright handles permanently over the photograph being judged. This is the
188
+ * model every avatar cropper and X itself settled on: aim the picture, and
189
+ * what is inside the frame is what is kept.
190
+ *
191
+ * So there is no crop rectangle to store. What is kept is exactly what the
192
+ * viewport shows, which the zoom and pan already describe.
193
+ */
194
+ /** Shape of the frame, after the rotation has been applied. */
195
+ const boxRatio = computed(() => {
196
+ if (typeof props.aspect === "number" && props.aspect > 0) return props.aspect
197
+ const ratio = ASPECTS[props.aspect as string]
198
+ if (ratio) return ratio
199
+ const size = sourceSize.value
200
+ if (!size) return 1
201
+ const turned = upright.value % 180 !== 0
202
+ const w = turned ? size.height : size.width
203
+ const h = turned ? size.width : size.height
204
+ return w / h
205
+ })
206
+
207
+ const frameStyle = computed(() => ({ aspectRatio: String(boxRatio.value) }))
208
+
209
+ const imageStyle = computed(() => ({
210
+ ...zoom.style.value,
211
+ // Rotation sits innermost, after the composable's scale and translate, so a
212
+ // drag still moves the picture the way the pointer went once it is turned.
213
+ transform: `${zoom.style.value.transform} rotate(${rotation.value}deg)`,
214
+ // A quarter turn travels. Snapping is how you lose track of which way the
215
+ // picture went, and the rest of the kit moves — a control that jumps reads
216
+ // as a different component. The composable suppresses its own transition
217
+ // while a drag is in flight, and that wins here too.
218
+ transition:
219
+ zoom.style.value.transition ??
220
+ `transform var(--duration-medium) var(--ease-emphasized)`,
221
+ }))
222
+
223
+ /*
224
+ * What the frame holds, as a Blob. `null` when nothing was changed.
225
+ *
226
+ * Taken from the rendered viewport rather than recomputed: what the person
227
+ * lined up on screen is what gets written, which is the one thing a cropper
228
+ * has to be right about.
229
+ */
230
+ async function exportImage(): Promise<Blob | null> {
231
+ const picture = source.value
232
+ const size = sourceSize.value
233
+ const frame = viewport.value
234
+ if (!picture || !size || !frame) return null
235
+ if (!edited.value && props.aspect === "free") return null
236
+
237
+ // Work in "upright space": the picture as the person sees it, already
238
+ // turned. The rotation is re-applied once, when drawing, which keeps the
239
+ // maths free of quarter-turn special cases.
240
+ const turned = upright.value % 180 !== 0
241
+ const uprightW = turned ? size.height : size.width
242
+ const uprightH = turned ? size.width : size.height
243
+
244
+ // object-cover: the picture fills the frame and the overflow is what panning
245
+ // reaches. One frame pixel is this many upright pixels.
246
+ const cover = Math.max(frame.clientWidth / uprightW, frame.clientHeight / uprightH)
247
+ const shown = cover * zoom.scale.value
248
+ const cropW = Math.min(uprightW, frame.clientWidth / shown)
249
+ const cropH = Math.min(uprightH, frame.clientHeight / shown)
250
+
251
+ // `offset` is in content pixels — the same space the cover scale maps from —
252
+ // so it converts with `cover`, not with the zoomed scale.
253
+ const centreX = clamp(
254
+ uprightW / 2 - zoom.offset.value.x / cover,
255
+ cropW / 2,
256
+ uprightW - cropW / 2,
257
+ )
258
+ const centreY = clamp(
259
+ uprightH / 2 - zoom.offset.value.y / cover,
260
+ cropH / 2,
261
+ uprightH - cropH / 2,
262
+ )
263
+
264
+ const scale = Math.min(1, props.maxEdge / Math.max(cropW, cropH))
265
+ const canvas = document.createElement("canvas")
266
+ canvas.width = Math.max(1, Math.round(cropW * scale))
267
+ canvas.height = Math.max(1, Math.round(cropH * scale))
268
+ const ctx = canvas.getContext("2d")
269
+ if (!ctx) return null
270
+
271
+ ctx.translate(canvas.width / 2, canvas.height / 2)
272
+ ctx.rotate((upright.value * Math.PI) / 180)
273
+ ctx.scale(scale, scale)
274
+
275
+ // Back to source coordinates for the read: a quarter turn swaps the axes.
276
+ const readW = turned ? cropH : cropW
277
+ const readH = turned ? cropW : cropH
278
+ const readX = (turned ? centreY : centreX) - readW / 2
279
+ const readY = (turned ? centreX : centreY) - readH / 2
280
+
281
+ ctx.drawImage(picture, readX, readY, readW, readH, -readW / 2, -readH / 2, readW, readH)
282
+
283
+ return await new Promise<Blob | null>((resolve) =>
284
+ canvas.toBlob(resolve, "image/jpeg", props.quality),
285
+ )
286
+ }
287
+
288
+ function clamp(value: number, low: number, high: number) {
289
+ return Math.min(Math.max(value, low), Math.max(low, high))
290
+ }
291
+
292
+ /*
293
+ * The wheel.
294
+ *
295
+ * Bare, it zooms — that is what it already did and what the lightbox does with
296
+ * it. Alt turns the picture instead, one degree a notch, fifteen with Shift.
297
+ *
298
+ * Alt and not Ctrl deliberately, even though Ctrl+wheel is the editor
299
+ * convention: the browser has claimed Ctrl+wheel for page zoom, every other
300
+ * application on the machine honours that, and a component in a web page that
301
+ * takes it over is fighting a reflex rather than serving one. Figma can do it
302
+ * because Figma is the whole page; a kit component is a guest on someone
303
+ * else's.
304
+ *
305
+ * A degree at a time, because this is the same control as levelling a crooked
306
+ * shot — the horizon adjustment and the free rotation are one gesture, not two
307
+ * features that happen to both turn the picture.
308
+ */
309
+ function onWheel(event: WheelEvent) {
310
+ if (event.altKey) {
311
+ event.preventDefault()
312
+ const step = event.shiftKey ? 15 : 1
313
+ rotation.value += Math.sign(event.deltaY) * step
314
+ return
315
+ }
316
+ zoom.onWheel(event)
317
+ }
318
+
319
+ /*
320
+ * Shortcuts, through the kit's registry rather than a switch of my own.
321
+ *
322
+ * useHotkeys answers three things a hand-rolled handler gets wrong, and one of
323
+ * them bites here specifically: on a Cyrillic layout "R" arrives as
324
+ * `key: "к"`, so matching the letter alone would leave the shortcut dead for
325
+ * the person most likely to be using this. Scoped to the viewport, so two
326
+ * editors on a page do not both answer the same key.
327
+ */
328
+ function pan(dx: number, dy: number, shift: boolean) {
329
+ // Panning moves the picture under a fixed frame, so the arrow goes the way
330
+ // the CONTENT should go — the opposite of scrolling a page.
331
+ const step = shift ? 40 : 10
332
+ const { x, y } = zoom.offset.value
333
+ zoom.setView({ x: x + dx * step, y: y + dy * step })
334
+ }
335
+
336
+ useHotkeys(
337
+ () => [
338
+ { key: "arrowleft", handler: () => pan(1, 0, false) },
339
+ { key: "arrowleft", shift: true, handler: () => pan(1, 0, true) },
340
+ { key: "arrowright", handler: () => pan(-1, 0, false) },
341
+ { key: "arrowright", shift: true, handler: () => pan(-1, 0, true) },
342
+ { key: "arrowup", handler: () => pan(0, 1, false) },
343
+ { key: "arrowup", shift: true, handler: () => pan(0, 1, true) },
344
+ { key: "arrowdown", handler: () => pan(0, -1, false) },
345
+ { key: "arrowdown", shift: true, handler: () => pan(0, -1, true) },
346
+ { key: "+", handler: () => zoom.zoomBy(1.3) },
347
+ { key: "=", handler: () => zoom.zoomBy(1.3) },
348
+ { key: "-", handler: () => zoom.zoomBy(1 / 1.3) },
349
+ { key: "r", code: "KeyR", handler: () => rotate(90) },
350
+ // Shift reverses it, the way a modifier reverses a direction elsewhere.
351
+ { key: "r", code: "KeyR", shift: true, handler: () => rotate(-90) },
352
+ { key: "0", code: "Digit0", handler: reset },
353
+ ],
354
+ { scope: viewport },
355
+ )
356
+
357
+ /** What the editor answers to, for a consumer building a shortcuts dialog. */
358
+ const SHORTCUTS = [
359
+ { keys: ["←", "→", "↑", "↓"], label: "Pan (Shift for a bigger step)" },
360
+ { keys: ["+", "−"], label: "Zoom" },
361
+ { keys: ["R"], label: "Rotate a quarter turn (Shift reverses)" },
362
+ { keys: ["Alt", "wheel"], label: "Turn freely (Shift snaps to 15°)" },
363
+ { keys: ["0"], label: "Reset" },
364
+ ] as const
365
+
366
+ defineExpose({ export: exportImage, reset, edited, shortcuts: SHORTCUTS })
367
+ </script>
368
+
369
+ <template>
370
+ <div ref="revealAnchor" class="flex flex-col gap-3">
371
+ <!-- The frame is the crop. The picture moves under it, which is the
372
+ interaction every avatar cropper already taught people. -->
373
+ <!-- Focusable, because everything here is otherwise a mouse gesture: a
374
+ person on a keyboard could reach the toolbar buttons and nothing else,
375
+ and panning a zoomed picture was unreachable entirely. -->
376
+ <div
377
+ ref="viewport"
378
+ tabindex="0"
379
+ role="application"
380
+ :aria-label="text.canvas"
381
+ class="relative w-full overflow-hidden rounded-card border border-line bg-surface-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
382
+ :style="frameStyle"
383
+ @wheel.prevent="onWheel"
384
+ @pointerdown="zoom.onPointerDown"
385
+ @pointermove="zoom.onPointerMove"
386
+ @pointerup="zoom.onPointerUp"
387
+ @pointercancel="zoom.onPointerUp"
388
+ >
389
+ <img
390
+ v-if="objectUrl"
391
+ :src="objectUrl"
392
+ alt=""
393
+ draggable="false"
394
+ class="lp-editor__image absolute inset-0 size-full select-none object-cover motion-reduce:transition-none"
395
+ :class="[
396
+ zoom.panning.value ? 'cursor-grabbing' : 'cursor-grab',
397
+ animate && !revealed ? 'lp-editor__image--waiting' : '',
398
+ ]"
399
+ :style="imageStyle"
400
+ >
401
+
402
+ <!-- Thirds, only while the picture is being moved. A composition guide
403
+ belongs to the moment of composing; left on, it is one more thing to
404
+ look past for all the time you are not. -->
405
+ <Transition
406
+ enter-active-class="transition-opacity duration-[var(--duration-fast)] motion-reduce:transition-none"
407
+ leave-active-class="transition-opacity duration-[var(--duration-fast)] motion-reduce:transition-none"
408
+ enter-from-class="opacity-0"
409
+ leave-to-class="opacity-0"
410
+ >
411
+ <div
412
+ v-if="zoom.panning.value"
413
+ class="pointer-events-none absolute inset-0 grid grid-cols-3 grid-rows-3"
414
+ >
415
+ <div
416
+ v-for="i in 9"
417
+ :key="i"
418
+ class="border-white/15"
419
+ :class="[i % 3 !== 0 ? 'border-r' : '', i < 7 ? 'border-b' : '']"
420
+ />
421
+ </div>
422
+ </Transition>
423
+ </div>
424
+
425
+ <!-- Two operations, shown as two. Five loose icons in a row — with two of
426
+ them opposite pairs — read as five unrelated things; grouped, each
427
+ pair is one control with a direction, and the zoom reading sits
428
+ between the buttons that move it. -->
429
+ <div class="flex items-center gap-1.5 text-muted">
430
+ <div class="flex items-center rounded-control bg-surface-soft p-0.5">
431
+ <LpButton
432
+ size="sm"
433
+ variant="ghost"
434
+ :title="text.rotateLeft"
435
+ :aria-label="text.rotateLeft"
436
+ @click="rotate(-90)"
437
+ >
438
+ <LpIcon name="lucide:rotate-ccw" :size="16" />
439
+ </LpButton>
440
+ <LpButton
441
+ size="sm"
442
+ variant="ghost"
443
+ :title="text.rotateRight"
444
+ :aria-label="text.rotateRight"
445
+ @click="rotate(90)"
446
+ >
447
+ <LpIcon name="lucide:rotate-cw" :size="16" />
448
+ </LpButton>
449
+ </div>
450
+
451
+ <div class="flex items-center rounded-control bg-surface-soft p-0.5">
452
+ <LpButton
453
+ size="sm"
454
+ variant="ghost"
455
+ :disabled="zoom.scale.value <= 1"
456
+ :title="text.zoomOut"
457
+ :aria-label="text.zoomOut"
458
+ @click="zoom.zoomBy(1 / 1.2)"
459
+ >
460
+ <LpIcon name="lucide:minus" :size="16" />
461
+ </LpButton>
462
+ <!-- Tabular, so the digits do not shuffle their own width as it moves. -->
463
+ <span class="min-w-10 text-center text-xs tabular-nums">
464
+ {{ Math.round(zoom.scale.value * 100) }}%
465
+ </span>
466
+ <LpButton
467
+ size="sm"
468
+ variant="ghost"
469
+ :title="text.zoomIn"
470
+ :aria-label="text.zoomIn"
471
+ @click="zoom.zoomBy(1.2)"
472
+ >
473
+ <LpIcon name="lucide:plus" :size="16" />
474
+ </LpButton>
475
+ </div>
476
+
477
+ <!-- Only once there is something to undo: a permanently dimmed control
478
+ is a question the eye answers on every pass. -->
479
+ <LpButton v-if="edited" size="sm" variant="ghost" class="ml-auto" @click="reset">
480
+ <LpIcon name="lucide:undo-2" :size="15" />
481
+ {{ text.reset }}
482
+ </LpButton>
483
+ </div>
484
+ </div>
485
+ </template>
486
+
487
+ <style scoped>
488
+ /* The picture settles into the frame instead of appearing in it. Scale rather
489
+ than a slide: the frame is the fixed thing here and the image lives inside
490
+ it, so anything that moved would read as a crop that had shifted. */
491
+ .lp-editor__image {
492
+ animation: lp-editor-in var(--duration-slow) var(--ease-settle) both;
493
+ }
494
+
495
+ .lp-editor__image--waiting {
496
+ opacity: 0;
497
+ animation: none;
498
+ }
499
+
500
+ @keyframes lp-editor-in {
501
+ from {
502
+ opacity: 0;
503
+ scale: 1.04;
504
+ }
505
+ }
506
+
507
+ @media (prefers-reduced-motion: reduce) {
508
+ .lp-editor__image,
509
+ .lp-editor__image--waiting {
510
+ opacity: 1;
511
+ scale: 1;
512
+ animation: none;
513
+ }
514
+ }
515
+ </style>
@@ -7,8 +7,18 @@
7
7
  * viewport element and re-emits the native scroll event — exactly what tailing
8
8
  * needs.
9
9
  */
10
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, TransitionGroup, watch } from "vue"
10
+ import {
11
+ computed,
12
+ nextTick,
13
+ onBeforeUnmount,
14
+ onMounted,
15
+ ref,
16
+ shallowRef,
17
+ TransitionGroup,
18
+ watch,
19
+ } from "vue"
11
20
  import { prefersReducedMotion } from "../composables/easing"
21
+ import { useHotkeys } from "../composables/useHotkeys"
12
22
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
13
23
  import LpIcon from "./LpIcon.vue"
14
24
  import LpScrollArea from "./LpScrollArea.vue"
@@ -52,6 +62,9 @@ const props = withDefaults(
52
62
  /** Fixed height. Anything CSS-valid; defaults to a comfortable terminal. */
53
63
  height?: string
54
64
  emptyLabel?: string
65
+ /** Shown while the stream is held. Defaulted in English; apps with i18n
66
+ * pass their own. */
67
+ frozenLabel?: string
55
68
  /**
56
69
  * When true, a "loading older…" row is shown pinned at the top — set it while
57
70
  * fetching a previous page in response to `reach-top`, clear it when done.
@@ -90,6 +103,7 @@ const props = withDefaults(
90
103
  tail: true,
91
104
  height: "20rem",
92
105
  emptyLabel: "No logs yet",
106
+ frozenLabel: "Held — release Ctrl to resume",
93
107
  rowMenu: true,
94
108
  parse: false,
95
109
  },
@@ -200,8 +214,85 @@ function parseLine(line: LogLine): LogLine {
200
214
  : { ...line, message: msg, time, level, source }
201
215
  }
202
216
 
217
+ /*
218
+ * Hold a key, the stream stops.
219
+ *
220
+ * Not a toggle. A stream worth freezing is one that is scrolling past faster
221
+ * than it can be read, and in that state you are looking at a line you want to
222
+ * keep in sight — reaching for a button means moving your eyes off it, and a
223
+ * toggle you must remember to switch back leaves the viewer dead until you do.
224
+ * The gesture is Task Manager's: press to hold, let go to resume, and nothing
225
+ * is lost either way because the lines were never the viewer's to begin with.
226
+ *
227
+ * The freeze bites at the SOURCE rather than at the scroll position. Pinning
228
+ * the scroll only stops it moving; the rows underneath still re-render, so
229
+ * whatever you were reading is replaced in place while it appears to stand
230
+ * still — which is worse than either state on its own.
231
+ */
232
+ const root = ref<HTMLElement | null>(null)
233
+ const frozen = ref(false)
234
+ const frozenLines = shallowRef<LogLine[] | null>(null)
235
+
236
+ const sourceLines = computed<LogLine[]>(() => frozenLines.value ?? props.lines)
237
+
238
+ function freeze(): boolean {
239
+ // Declining unless the stream is being looked at: `false` hands Ctrl back
240
+ // untouched, which matters because Ctrl is half of every shortcut on the
241
+ // page and swallowing it would break them all.
242
+ if (!hovered.value && !focusWithin()) return false
243
+ if (frozen.value) return true
244
+ // Snapshotted by reference, not copied: the array the consumer hands over is
245
+ // replaced wholesale on every update, so holding the old one is enough — and
246
+ // a copy of ten thousand lines on every keypress is not.
247
+ frozenLines.value = props.lines
248
+ frozen.value = true
249
+ return true
250
+ }
251
+
252
+ function thaw() {
253
+ frozen.value = false
254
+ frozenLines.value = null
255
+ }
256
+
257
+ /*
258
+ * Held while the pointer is over the stream, or while it holds the focus.
259
+ *
260
+ * Not scoped to focus alone: nobody clicks a log before reading it, and a
261
+ * modifier held over an unfocused viewer would have gone to the page instead.
262
+ * Not global either — two viewers side by side would both freeze, and Ctrl is
263
+ * pressed for a hundred other reasons on a page that happens to contain logs.
264
+ * Pointing at the thing is the intent.
265
+ */
266
+ const hovered = ref(false)
267
+
268
+ function focusWithin() {
269
+ const el = root.value
270
+ return !!el && el.contains(document.activeElement)
271
+ }
272
+
273
+ useHotkeys(() => [
274
+ { key: "control", allowInInput: true, handler: freeze },
275
+ { key: "meta", allowInInput: true, handler: freeze },
276
+ ])
277
+
278
+ // Released on keyup anywhere, and on blur: a window that loses focus mid-hold
279
+ // never delivers the keyup, and the viewer would stay frozen with no visible
280
+ // reason and no key to press.
281
+ function onKeyup(event: KeyboardEvent) {
282
+ if (event.key === "Control" || event.key === "Meta") thaw()
283
+ }
284
+
285
+ onMounted(() => {
286
+ window.addEventListener("keyup", onKeyup)
287
+ window.addEventListener("blur", thaw)
288
+ })
289
+ onBeforeUnmount(() => {
290
+ window.removeEventListener("keyup", onKeyup)
291
+ window.removeEventListener("blur", thaw)
292
+ })
293
+
203
294
  const parsedLines = computed<LogLine[]>(() =>
204
- props.parse ? props.lines.map(parseLine) : props.lines,
295
+ props.parse ? sourceLines.value.map(parseLine) : sourceLines.value,
205
296
  )
206
297
 
207
298
  // Rows actually rendered, each carrying its ORIGINAL index so line numbers stay
@@ -548,8 +639,29 @@ const transitionProps = {
548
639
 
549
640
  <template>
550
641
  <div
642
+ ref="root"
643
+ tabindex="-1"
551
644
  class="relative overflow-hidden rounded-card border border-line bg-surface font-mono text-xs leading-relaxed"
645
+ :class="frozen ? 'ring-1 ring-brand' : ''"
646
+ @pointerenter="hovered = true"
647
+ @pointerleave="hovered = false"
552
648
  >
649
+ <!-- Says which state it is in, because a stream that has simply gone quiet
650
+ looks exactly like a frozen one. -->
651
+ <Transition
652
+ enter-active-class="transition duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
653
+ leave-active-class="transition duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
654
+ enter-from-class="-translate-y-1 opacity-0"
655
+ leave-to-class="-translate-y-1 opacity-0"
656
+ >
657
+ <div
658
+ v-if="frozen"
659
+ class="pointer-events-none absolute top-2 left-1/2 z-10 -translate-x-1/2 rounded-pill bg-brand px-2.5 py-1 text-[11px] font-medium text-ink-inverse"
660
+ >
661
+ {{ frozenLabel }}
662
+ </div>
663
+ </Transition>
664
+
553
665
  <!-- `instant`: this viewer decides for itself when a jump animates (see
554
666
  rideToBottom) and restores scrollTop directly after prepending older
555
667
  lines. A CSS smooth scroll would override both. -->