@leavepulse/ui 0.28.0 → 0.28.2
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/assets/LpDropzone-DVDC-ABU.css +52 -0
- package/dist/assets/LpImageEditor-DqP5JKCy.css +25 -0
- package/dist/chunks/{LpLogViewer-CcQ3s3Fp.js → LpLogViewer-CKmEQYnm.js} +66 -8
- package/dist/chunks/{LpNumberField-B3JuQFol.js → LpNumberField-NPE8Bumx.js} +14 -3
- package/dist/chunks/{LpProgress-DoIP4jQR.js → LpProgress-Btq5W4Ut.js} +14 -14
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpDropzone.vue.d.ts +66 -0
- package/dist/components/LpDropzone.vue.js +163 -0
- package/dist/components/LpImageEditor.vue.d.ts +58 -0
- package/dist/components/LpImageEditor.vue.js +357 -0
- package/dist/components/LpLogViewer.vue.d.ts +4 -0
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpNumberField.vue.d.ts +1 -0
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpProgress.vue.js +1 -1
- package/dist/composables/useHotkeys.d.ts +16 -1
- package/dist/composables/useHotkeys.js +8 -1
- package/dist/composables/useZoomPan.d.ts +24 -0
- package/dist/composables/useZoomPan.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -5
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpDropzone.vue +299 -0
- package/src/components/LpImageEditor.vue +515 -0
- package/src/components/LpLogViewer.vue +114 -2
- package/src/components/LpNumberField.vue +16 -0
- package/src/components/LpProgress.vue +30 -21
- package/src/composables/useHotkeys.ts +30 -1
- package/src/composables/useZoomPan.ts +30 -0
- package/src/index.ts +2 -0
|
@@ -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 {
|
|
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 ?
|
|
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. -->
|