@leavepulse/ui 0.28.1 → 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/{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/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 +8 -4
- 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/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,299 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* A place to put files: drop them, click to browse, or paste from the
|
|
4
|
+
* clipboard.
|
|
5
|
+
*
|
|
6
|
+
* All three, because they are the same intent arriving by different routes and
|
|
7
|
+
* a zone that only accepts one of them is the one people call broken. Paste in
|
|
8
|
+
* particular is what a screenshot is: cropped with the OS tool, Ctrl+V, done —
|
|
9
|
+
* there is no file on disk to browse for.
|
|
10
|
+
*
|
|
11
|
+
* Not tied to images. `accept` is passed to the input and used to filter what a
|
|
12
|
+
* drop actually yields, so this is equally the place a YAML import or an
|
|
13
|
+
* attachment lands.
|
|
14
|
+
*
|
|
15
|
+
* The zone is a BUTTON. It was a div with a click handler first, which is
|
|
16
|
+
* invisible to a keyboard: nothing focusable, nothing that Enter activates, and
|
|
17
|
+
* the file dialog unreachable without a mouse.
|
|
18
|
+
*/
|
|
19
|
+
import { onBeforeUnmount, onMounted, ref } from "vue"
|
|
20
|
+
import { useReveal } from "../composables/useReveal"
|
|
21
|
+
import LpIcon from "./LpIcon.vue"
|
|
22
|
+
|
|
23
|
+
const props = withDefaults(
|
|
24
|
+
defineProps<{
|
|
25
|
+
/** Same syntax as the input's: "image/*", ".yaml,.yml". */
|
|
26
|
+
accept?: string
|
|
27
|
+
/** Take more than one at a time. */
|
|
28
|
+
multiple?: boolean
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Also accept a paste anywhere on the page, not only while focused.
|
|
32
|
+
* Off by default: two zones both listening would each take the same
|
|
33
|
+
* screenshot, and a page with one obvious target is the case for it.
|
|
34
|
+
*/
|
|
35
|
+
pasteTarget?: boolean
|
|
36
|
+
/** Largest file accepted, in bytes. Bigger ones are rejected, not silently
|
|
37
|
+
* dropped — a picture that vanishes reads as a broken uploader. */
|
|
38
|
+
maxBytes?: number
|
|
39
|
+
icon?: string
|
|
40
|
+
title?: string
|
|
41
|
+
hint?: string
|
|
42
|
+
/**
|
|
43
|
+
* How the well sits against what holds it. A drop target reads as a hole
|
|
44
|
+
* in the surface, not a tile on top of it, so the default is darker than
|
|
45
|
+
* the card — which is what a dashed border is already suggesting.
|
|
46
|
+
* `flush` for a zone that fills a panel of its own, `soft` when it sits
|
|
47
|
+
* directly on the page background and needs to lift off it instead.
|
|
48
|
+
*/
|
|
49
|
+
tone?: "sunken" | "flush" | "soft"
|
|
50
|
+
/** Draw the entrance when the zone first comes into view. */
|
|
51
|
+
animate?: boolean
|
|
52
|
+
}>(),
|
|
53
|
+
{
|
|
54
|
+
accept: "",
|
|
55
|
+
multiple: false,
|
|
56
|
+
icon: "lucide:upload",
|
|
57
|
+
title: "",
|
|
58
|
+
hint: "",
|
|
59
|
+
tone: "sunken",
|
|
60
|
+
animate: true,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const TONES = {
|
|
65
|
+
sunken: "bg-surface",
|
|
66
|
+
flush: "bg-surface-raised",
|
|
67
|
+
soft: "bg-surface-soft",
|
|
68
|
+
} as const
|
|
69
|
+
|
|
70
|
+
// Entrance on arrival, not on mount — the kit's rule, and the reason a zone in
|
|
71
|
+
// a modal that opens later still draws rather than being found already there.
|
|
72
|
+
const { el: revealAnchor, revealed } = useReveal({ immediate: () => !props.animate })
|
|
73
|
+
|
|
74
|
+
const emit = defineEmits<{
|
|
75
|
+
(e: "files", files: File[]): void
|
|
76
|
+
/** Something was refused, and why — the caller decides how loudly to say so. */
|
|
77
|
+
(e: "rejected", reason: { file: File; cause: "type" | "size" }[]): void
|
|
78
|
+
}>()
|
|
79
|
+
|
|
80
|
+
const input = ref<HTMLInputElement | null>(null)
|
|
81
|
+
/*
|
|
82
|
+
* Is a drag over the zone?
|
|
83
|
+
*
|
|
84
|
+
* Answered by asking where the pointer is, not by counting enter/leave pairs.
|
|
85
|
+
* The count is the usual trick and it does not balance: `dragenter` fires for
|
|
86
|
+
* every child the pointer crosses and bubbles, while the matching `dragleave`
|
|
87
|
+
* may not — one stuck increment and the zone stays lit after the drag is long
|
|
88
|
+
* gone, which is exactly what it did.
|
|
89
|
+
*/
|
|
90
|
+
const over = ref(false)
|
|
91
|
+
|
|
92
|
+
function onDragOver(event: DragEvent) {
|
|
93
|
+
if (props.disabled) return
|
|
94
|
+
over.value = true
|
|
95
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function onDragLeave(event: DragEvent) {
|
|
99
|
+
// relatedTarget is where the pointer went. Still inside the zone means the
|
|
100
|
+
// drag merely crossed a child, and the highlight stays.
|
|
101
|
+
const to = event.relatedTarget as Node | null
|
|
102
|
+
if (to && (event.currentTarget as HTMLElement).contains(to)) return
|
|
103
|
+
over.value = false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Does the file match `accept`? Mirrors the input's own matching so a drop
|
|
107
|
+
* and a browse cannot disagree about what is allowed. */
|
|
108
|
+
function matches(file: File): boolean {
|
|
109
|
+
if (!props.accept) return true
|
|
110
|
+
return props.accept.split(",").some((rule) => {
|
|
111
|
+
const pattern = rule.trim().toLowerCase()
|
|
112
|
+
if (!pattern) return false
|
|
113
|
+
if (pattern.startsWith(".")) return file.name.toLowerCase().endsWith(pattern)
|
|
114
|
+
if (pattern.endsWith("/*")) return file.type.startsWith(pattern.slice(0, -1))
|
|
115
|
+
return file.type.toLowerCase() === pattern
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function take(list: FileList | File[] | null | undefined) {
|
|
120
|
+
if (props.disabled || !list) return
|
|
121
|
+
const all = Array.from(list)
|
|
122
|
+
const kept: File[] = []
|
|
123
|
+
const refused: { file: File; cause: "type" | "size" }[] = []
|
|
124
|
+
|
|
125
|
+
for (const file of all) {
|
|
126
|
+
if (!matches(file)) refused.push({ file, cause: "type" })
|
|
127
|
+
else if (props.maxBytes && file.size > props.maxBytes) {
|
|
128
|
+
refused.push({ file, cause: "size" })
|
|
129
|
+
} else kept.push(file)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// `multiple` bounds the result rather than rejecting the extras: dropping a
|
|
133
|
+
// folder of photographs on a single-file zone should take the first, not
|
|
134
|
+
// refuse the lot.
|
|
135
|
+
const bounded = props.multiple ? kept : kept.slice(0, 1)
|
|
136
|
+
if (refused.length) emit("rejected", refused)
|
|
137
|
+
if (bounded.length) emit("files", bounded)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function onDrop(event: DragEvent) {
|
|
141
|
+
over.value = false
|
|
142
|
+
take(event.dataTransfer?.files)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onPaste(event: ClipboardEvent) {
|
|
146
|
+
const files = Array.from(event.clipboardData?.files ?? [])
|
|
147
|
+
if (!files.length) return
|
|
148
|
+
// Only swallow the paste when it actually carried a file: a paste into a
|
|
149
|
+
// field that happens to sit inside the zone must still reach the field.
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
take(files)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onPicked(event: Event) {
|
|
155
|
+
const el = event.target as HTMLInputElement
|
|
156
|
+
take(el.files)
|
|
157
|
+
// Cleared so picking the same file twice in a row fires again — otherwise
|
|
158
|
+
// "I chose the wrong one, let me redo it" silently does nothing.
|
|
159
|
+
el.value = ""
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function windowPaste(event: ClipboardEvent) {
|
|
163
|
+
if (props.disabled) return
|
|
164
|
+
const target = event.target as HTMLElement | null
|
|
165
|
+
// A paste aimed at somewhere you can type belongs to that thing.
|
|
166
|
+
if (target?.closest("input, textarea, [contenteditable]")) return
|
|
167
|
+
onPaste(event)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
onMounted(() => {
|
|
171
|
+
if (props.pasteTarget) window.addEventListener("paste", windowPaste)
|
|
172
|
+
})
|
|
173
|
+
onBeforeUnmount(() => window.removeEventListener("paste", windowPaste))
|
|
174
|
+
|
|
175
|
+
defineExpose({ browse: () => input.value?.click() })
|
|
176
|
+
</script>
|
|
177
|
+
|
|
178
|
+
<template>
|
|
179
|
+
<button
|
|
180
|
+
ref="revealAnchor"
|
|
181
|
+
type="button"
|
|
182
|
+
:disabled="disabled"
|
|
183
|
+
class="lp-dropzone relative flex w-full flex-col items-center gap-2 rounded-card px-6 py-8 text-center transition-colors duration-[var(--duration-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-55"
|
|
184
|
+
:class="[
|
|
185
|
+
over ? 'bg-brand-soft' : TONES[tone],
|
|
186
|
+
over ? 'lp-dropzone--over' : '',
|
|
187
|
+
animate && !revealed ? 'lp-dropzone--waiting' : '',
|
|
188
|
+
]"
|
|
189
|
+
@click="input?.click()"
|
|
190
|
+
@paste="onPaste"
|
|
191
|
+
@dragenter.prevent="onDragOver"
|
|
192
|
+
@dragover.prevent="onDragOver"
|
|
193
|
+
@dragleave="onDragLeave"
|
|
194
|
+
@drop.prevent="onDrop"
|
|
195
|
+
>
|
|
196
|
+
<!-- The border is drawn rather than declared, because `border-style:
|
|
197
|
+
dashed` cannot be moved: a CSS border has no dash offset to animate.
|
|
198
|
+
At rest it is a plain dashed outline; while something is being dragged
|
|
199
|
+
over it the dashes travel, which answers "yes, let go here" to
|
|
200
|
+
peripheral vision — the pointer is what the person is watching, not
|
|
201
|
+
this box. -->
|
|
202
|
+
<svg
|
|
203
|
+
aria-hidden="true"
|
|
204
|
+
class="lp-dropzone__frame pointer-events-none absolute inset-0 size-full"
|
|
205
|
+
>
|
|
206
|
+
<rect
|
|
207
|
+
x="0.5"
|
|
208
|
+
y="0.5"
|
|
209
|
+
width="calc(100% - 1px)"
|
|
210
|
+
height="calc(100% - 1px)"
|
|
211
|
+
rx="var(--radius-card)"
|
|
212
|
+
fill="none"
|
|
213
|
+
stroke-width="1"
|
|
214
|
+
stroke-dasharray="6 5"
|
|
215
|
+
/>
|
|
216
|
+
</svg>
|
|
217
|
+
|
|
218
|
+
<slot :over="over">
|
|
219
|
+
<LpIcon
|
|
220
|
+
:name="icon"
|
|
221
|
+
:size="22"
|
|
222
|
+
class="text-muted transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] motion-reduce:transition-none"
|
|
223
|
+
:class="over ? 'scale-110 text-brand' : ''"
|
|
224
|
+
/>
|
|
225
|
+
<span v-if="title" class="text-sm font-medium text-ink">{{ title }}</span>
|
|
226
|
+
<span v-if="hint" class="text-xs text-muted">{{ hint }}</span>
|
|
227
|
+
</slot>
|
|
228
|
+
|
|
229
|
+
<input
|
|
230
|
+
ref="input"
|
|
231
|
+
type="file"
|
|
232
|
+
class="hidden"
|
|
233
|
+
:accept="accept || undefined"
|
|
234
|
+
:multiple="multiple"
|
|
235
|
+
:disabled="disabled"
|
|
236
|
+
@change="onPicked"
|
|
237
|
+
@click.stop
|
|
238
|
+
>
|
|
239
|
+
</button>
|
|
240
|
+
</template>
|
|
241
|
+
|
|
242
|
+
<style scoped>
|
|
243
|
+
.lp-dropzone__frame rect {
|
|
244
|
+
stroke: var(--color-line);
|
|
245
|
+
transition: stroke var(--duration-fast) linear;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.lp-dropzone:hover .lp-dropzone__frame rect {
|
|
249
|
+
stroke: var(--color-line-strong);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* Marching ants, and only while a drag is in flight. At rest this would be a
|
|
253
|
+
loop nobody asked for, running for as long as the page is open and competing
|
|
254
|
+
with every other thing on it; under a drag it is the answer to "can I let go
|
|
255
|
+
here". One dash period per cycle, so the travel is continuous rather than
|
|
256
|
+
jumping back at the seam. */
|
|
257
|
+
.lp-dropzone--over .lp-dropzone__frame rect {
|
|
258
|
+
stroke: var(--color-brand);
|
|
259
|
+
animation: lp-dropzone-ants 500ms linear infinite;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@keyframes lp-dropzone-ants {
|
|
263
|
+
to {
|
|
264
|
+
stroke-dashoffset: -11;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* The dashes fade in and the block settles up a few pixels. */
|
|
269
|
+
.lp-dropzone {
|
|
270
|
+
animation: lp-dropzone-in var(--duration-slow) var(--ease-settle) both;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.lp-dropzone--waiting {
|
|
274
|
+
opacity: 0;
|
|
275
|
+
animation: none;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@keyframes lp-dropzone-in {
|
|
279
|
+
from {
|
|
280
|
+
opacity: 0;
|
|
281
|
+
transform: translateY(6px);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@media (prefers-reduced-motion: reduce) {
|
|
286
|
+
.lp-dropzone,
|
|
287
|
+
.lp-dropzone--waiting {
|
|
288
|
+
opacity: 1;
|
|
289
|
+
transform: none;
|
|
290
|
+
animation: none;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* The ants stop, the colour stays: the brand stroke is what says "drop
|
|
294
|
+
here", and it says it standing still. */
|
|
295
|
+
.lp-dropzone--over .lp-dropzone__frame rect {
|
|
296
|
+
animation: none;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
</style>
|