@leavepulse/ui 0.37.7 → 0.37.9
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/{LpImageEditor-BsSwej47.css → LpImageEditor-D-s8tNGO.css} +6 -6
- package/dist/components/LpImageEditor.vue.d.ts +27 -2
- package/dist/components/LpImageEditor.vue.js +9 -3
- package/dist/components/LpScrollArea.vue.d.ts +1 -1
- package/dist/composables/useShift.js +17 -7
- package/package.json +1 -1
- package/src/components/LpImageEditor.vue +22 -3
- package/src/composables/useShift.ts +27 -8
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
/* The picture settles into the frame instead of appearing in it. Scale rather
|
|
3
3
|
than a slide: the frame is the fixed thing here and the image lives inside
|
|
4
4
|
it, so anything that moved would read as a crop that had shifted. */
|
|
5
|
-
.lp-editor__image[data-v-
|
|
6
|
-
animation: lp-editor-in-
|
|
5
|
+
.lp-editor__image[data-v-667d4388] {
|
|
6
|
+
animation: lp-editor-in-667d4388 var(--duration-slow) var(--ease-settle) both;
|
|
7
7
|
}
|
|
8
|
-
.lp-editor__image--waiting[data-v-
|
|
8
|
+
.lp-editor__image--waiting[data-v-667d4388] {
|
|
9
9
|
opacity: 0;
|
|
10
10
|
animation: none;
|
|
11
11
|
}
|
|
12
|
-
@keyframes lp-editor-in-
|
|
12
|
+
@keyframes lp-editor-in-667d4388 {
|
|
13
13
|
from {
|
|
14
14
|
opacity: 0;
|
|
15
15
|
scale: 1.04;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
@media (prefers-reduced-motion: reduce) {
|
|
19
|
-
.lp-editor__image[data-v-
|
|
20
|
-
.lp-editor__image--waiting[data-v-
|
|
19
|
+
.lp-editor__image[data-v-667d4388],
|
|
20
|
+
.lp-editor__image--waiting[data-v-667d4388] {
|
|
21
21
|
opacity: 1;
|
|
22
22
|
scale: 1;
|
|
23
23
|
animation: none;
|
|
@@ -15,7 +15,19 @@ declare const __VLS_export: import('vue').DefineComponent<{
|
|
|
15
15
|
aspect?: "free" | "square" | "4:3" | "3:4" | "16:9" | number;
|
|
16
16
|
/** Longest edge of the exported image. Larger sources are scaled down. */
|
|
17
17
|
maxEdge?: number;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Encoding of the export; read the result's `blob.type` for the one used.
|
|
20
|
+
*
|
|
21
|
+
* JPEG, the default, is the smallest for photographs, the case this was
|
|
22
|
+
* built for. It has no alpha channel, though: a transparent PNG comes back
|
|
23
|
+
* with its background filled black, and a logo or an icon is ruined by the
|
|
24
|
+
* crop. "source" keeps the picture's own format where a canvas can write it
|
|
25
|
+
* (PNG, JPEG, WebP) and falls back to PNG for the ones it cannot (GIF, BMP,
|
|
26
|
+
* SVG), so transparency survives either way. An animated GIF still comes
|
|
27
|
+
* back as its first frame: a canvas holds one picture.
|
|
28
|
+
*/
|
|
29
|
+
type?: "image/jpeg" | "image/png" | "image/webp" | "source";
|
|
30
|
+
/** Quality of a lossy export (JPEG, WebP); PNG ignores it. */
|
|
19
31
|
quality?: number;
|
|
20
32
|
/**
|
|
21
33
|
* Labels for the toolbar. Defaulted in English rather than left blank: an
|
|
@@ -61,7 +73,19 @@ declare const __VLS_export: import('vue').DefineComponent<{
|
|
|
61
73
|
aspect?: "free" | "square" | "4:3" | "3:4" | "16:9" | number;
|
|
62
74
|
/** Longest edge of the exported image. Larger sources are scaled down. */
|
|
63
75
|
maxEdge?: number;
|
|
64
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Encoding of the export; read the result's `blob.type` for the one used.
|
|
78
|
+
*
|
|
79
|
+
* JPEG, the default, is the smallest for photographs, the case this was
|
|
80
|
+
* built for. It has no alpha channel, though: a transparent PNG comes back
|
|
81
|
+
* with its background filled black, and a logo or an icon is ruined by the
|
|
82
|
+
* crop. "source" keeps the picture's own format where a canvas can write it
|
|
83
|
+
* (PNG, JPEG, WebP) and falls back to PNG for the ones it cannot (GIF, BMP,
|
|
84
|
+
* SVG), so transparency survives either way. An animated GIF still comes
|
|
85
|
+
* back as its first frame: a canvas holds one picture.
|
|
86
|
+
*/
|
|
87
|
+
type?: "image/jpeg" | "image/png" | "image/webp" | "source";
|
|
88
|
+
/** Quality of a lossy export (JPEG, WebP); PNG ignores it. */
|
|
65
89
|
quality?: number;
|
|
66
90
|
/**
|
|
67
91
|
* Labels for the toolbar. Defaulted in English rather than left blank: an
|
|
@@ -73,6 +97,7 @@ declare const __VLS_export: import('vue').DefineComponent<{
|
|
|
73
97
|
/** Fade the picture in when the editor first comes into view. */
|
|
74
98
|
animate?: boolean;
|
|
75
99
|
}> & Readonly<{}>, {
|
|
100
|
+
type: "image/jpeg" | "image/png" | "image/webp" | "source";
|
|
76
101
|
animate: boolean;
|
|
77
102
|
labels: Partial<Record<"rotateLeft" | "rotateRight" | "zoomIn" | "zoomOut" | "reset" | "canvas", string>>;
|
|
78
103
|
aspect: "free" | "square" | "4:3" | "3:4" | "16:9" | number;
|
|
@@ -7,7 +7,7 @@ import "reka-ui";
|
|
|
7
7
|
import { _ as _sfc_main$2 } from "../chunks/LpIcon-CCnX5_2j.js";
|
|
8
8
|
import { useMergedAttrs } from "../composables/useMergedClass.js";
|
|
9
9
|
import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
|
|
10
|
-
import '../assets/LpImageEditor-
|
|
10
|
+
import '../assets/LpImageEditor-D-s8tNGO.css';const _hoisted_1 = ["aria-label"];
|
|
11
11
|
const _hoisted_2 = ["src"];
|
|
12
12
|
const _hoisted_3 = {
|
|
13
13
|
key: 0,
|
|
@@ -25,6 +25,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
25
25
|
file: {},
|
|
26
26
|
aspect: { default: "free" },
|
|
27
27
|
maxEdge: { default: 2400 },
|
|
28
|
+
type: { default: "image/jpeg" },
|
|
28
29
|
quality: { default: 0.9 },
|
|
29
30
|
labels: { default: () => ({}) },
|
|
30
31
|
animate: { type: Boolean, default: true }
|
|
@@ -164,9 +165,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
164
165
|
const readY = (turned ? centreX : centreY) - readH / 2;
|
|
165
166
|
ctx.drawImage(picture, readX, readY, readW, readH, -readW / 2, -readH / 2, readW, readH);
|
|
166
167
|
return await new Promise(
|
|
167
|
-
(resolve) => canvas.toBlob(resolve,
|
|
168
|
+
(resolve) => canvas.toBlob(resolve, outputType.value, props.quality)
|
|
168
169
|
);
|
|
169
170
|
}
|
|
171
|
+
const ENCODABLE = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
172
|
+
const outputType = computed(() => {
|
|
173
|
+
if (props.type !== "source") return props.type;
|
|
174
|
+
return ENCODABLE.has(props.file.type) ? props.file.type : "image/png";
|
|
175
|
+
});
|
|
170
176
|
function clamp(value, low, high) {
|
|
171
177
|
return Math.min(Math.max(value, low), Math.max(low, high));
|
|
172
178
|
}
|
|
@@ -357,7 +363,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
357
363
|
};
|
|
358
364
|
}
|
|
359
365
|
});
|
|
360
|
-
const LpImageEditor = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-
|
|
366
|
+
const LpImageEditor = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-667d4388"]]);
|
|
361
367
|
export {
|
|
362
368
|
LpImageEditor as default
|
|
363
369
|
};
|
|
@@ -15,8 +15,8 @@ declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {
|
|
|
15
15
|
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
16
16
|
onScroll?: ((event: Event) => any) | undefined;
|
|
17
17
|
}>, {
|
|
18
|
-
fade: boolean;
|
|
19
18
|
instant: boolean;
|
|
19
|
+
fade: boolean;
|
|
20
20
|
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
21
21
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
22
22
|
declare const _default: typeof __VLS_export;
|
|
@@ -14,15 +14,24 @@ function useShift(options = {}) {
|
|
|
14
14
|
let lastWidth = 0;
|
|
15
15
|
let inFlight = false;
|
|
16
16
|
let tweenStart = 0;
|
|
17
|
-
const scrolled = /* @__PURE__ */ new
|
|
17
|
+
const scrolled = /* @__PURE__ */ new Set();
|
|
18
18
|
const rememberScroll = (event) => {
|
|
19
19
|
const target = event.target;
|
|
20
|
-
if (target instanceof Element) scrolled.
|
|
20
|
+
if (target instanceof Element) scrolled.add(target);
|
|
21
21
|
};
|
|
22
|
-
function
|
|
23
|
-
|
|
22
|
+
function readScroll() {
|
|
23
|
+
const tops = /* @__PURE__ */ new Map();
|
|
24
|
+
for (const node of scrolled) {
|
|
24
25
|
if (!node.isConnected) scrolled.delete(node);
|
|
25
|
-
else
|
|
26
|
+
else tops.set(node, node.scrollTop);
|
|
27
|
+
}
|
|
28
|
+
return tops;
|
|
29
|
+
}
|
|
30
|
+
function restoreScroll(tops) {
|
|
31
|
+
for (const [node, top] of tops) {
|
|
32
|
+
if (node.isConnected && node.scrollTop !== top) {
|
|
33
|
+
node.scrollTo({ top, behavior: "instant" });
|
|
34
|
+
}
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
const axisOf = () => toValue(options.axis) ?? "height";
|
|
@@ -39,6 +48,7 @@ function useShift(options = {}) {
|
|
|
39
48
|
}
|
|
40
49
|
const fromHeight = lastHeight;
|
|
41
50
|
const fromWidth = lastWidth;
|
|
51
|
+
const tops = readScroll();
|
|
42
52
|
if (wantsHeight()) {
|
|
43
53
|
node.style.height = "";
|
|
44
54
|
node.style.maxHeight = "none";
|
|
@@ -51,7 +61,7 @@ function useShift(options = {}) {
|
|
|
51
61
|
const toWidth = wantsWidth() ? node.offsetWidth : 0;
|
|
52
62
|
if (wantsHeight()) node.style.maxHeight = "";
|
|
53
63
|
if (wantsWidth()) node.style.maxWidth = "";
|
|
54
|
-
restoreScroll();
|
|
64
|
+
restoreScroll(tops);
|
|
55
65
|
lastHeight = toHeight;
|
|
56
66
|
lastWidth = toWidth;
|
|
57
67
|
const movesHeight = wantsHeight() && fromHeight !== 0 && toHeight !== fromHeight;
|
|
@@ -62,7 +72,7 @@ function useShift(options = {}) {
|
|
|
62
72
|
void node.offsetHeight;
|
|
63
73
|
if (movesHeight) node.style.height = `${toHeight}px`;
|
|
64
74
|
if (movesWidth) node.style.width = `${toWidth}px`;
|
|
65
|
-
restoreScroll();
|
|
75
|
+
restoreScroll(tops);
|
|
66
76
|
inFlight = true;
|
|
67
77
|
tweenStart = performance.now();
|
|
68
78
|
resizing.value = true;
|
package/package.json
CHANGED
|
@@ -48,7 +48,19 @@ const props = withDefaults(
|
|
|
48
48
|
aspect?: "free" | "square" | "4:3" | "3:4" | "16:9" | number
|
|
49
49
|
/** Longest edge of the exported image. Larger sources are scaled down. */
|
|
50
50
|
maxEdge?: number
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Encoding of the export; read the result's `blob.type` for the one used.
|
|
53
|
+
*
|
|
54
|
+
* JPEG, the default, is the smallest for photographs, the case this was
|
|
55
|
+
* built for. It has no alpha channel, though: a transparent PNG comes back
|
|
56
|
+
* with its background filled black, and a logo or an icon is ruined by the
|
|
57
|
+
* crop. "source" keeps the picture's own format where a canvas can write it
|
|
58
|
+
* (PNG, JPEG, WebP) and falls back to PNG for the ones it cannot (GIF, BMP,
|
|
59
|
+
* SVG), so transparency survives either way. An animated GIF still comes
|
|
60
|
+
* back as its first frame: a canvas holds one picture.
|
|
61
|
+
*/
|
|
62
|
+
type?: "image/jpeg" | "image/png" | "image/webp" | "source"
|
|
63
|
+
/** Quality of a lossy export (JPEG, WebP); PNG ignores it. */
|
|
52
64
|
quality?: number
|
|
53
65
|
/**
|
|
54
66
|
* Labels for the toolbar. Defaulted in English rather than left blank: an
|
|
@@ -62,7 +74,7 @@ const props = withDefaults(
|
|
|
62
74
|
/** Fade the picture in when the editor first comes into view. */
|
|
63
75
|
animate?: boolean
|
|
64
76
|
}>(),
|
|
65
|
-
{ aspect: "free", maxEdge: 2400, quality: 0.9, labels: () => ({}), animate: true },
|
|
77
|
+
{ aspect: "free", maxEdge: 2400, type: "image/jpeg", quality: 0.9, labels: () => ({}), animate: true },
|
|
66
78
|
)
|
|
67
79
|
|
|
68
80
|
// Same rule as the rest of the kit: the entrance belongs to arrival, not to
|
|
@@ -289,10 +301,17 @@ async function exportImage(): Promise<Blob | null> {
|
|
|
289
301
|
ctx.drawImage(picture, readX, readY, readW, readH, -readW / 2, -readH / 2, readW, readH)
|
|
290
302
|
|
|
291
303
|
return await new Promise<Blob | null>((resolve) =>
|
|
292
|
-
canvas.toBlob(resolve,
|
|
304
|
+
canvas.toBlob(resolve, outputType.value, props.quality),
|
|
293
305
|
)
|
|
294
306
|
}
|
|
295
307
|
|
|
308
|
+
const ENCODABLE = new Set(["image/png", "image/jpeg", "image/webp"])
|
|
309
|
+
|
|
310
|
+
const outputType = computed(() => {
|
|
311
|
+
if (props.type !== "source") return props.type
|
|
312
|
+
return ENCODABLE.has(props.file.type) ? props.file.type : "image/png"
|
|
313
|
+
})
|
|
314
|
+
|
|
296
315
|
function clamp(value: number, low: number, high: number) {
|
|
297
316
|
return Math.min(Math.max(value, low), Math.max(low, high))
|
|
298
317
|
}
|
|
@@ -92,22 +92,40 @@ export function useShift(
|
|
|
92
92
|
/** When the in-flight tween started, to tell a duplicate from a follow-up. */
|
|
93
93
|
let tweenStart = 0
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
95
|
+
* Scrolling lists inside the box, found through their scroll events.
|
|
96
96
|
* Measuring lifts the height cap, and for that one reflow a scrolling list
|
|
97
97
|
* inside (a picker, a span tree) is as tall as its content: it no longer
|
|
98
98
|
* overflows, the browser clamps its scrollTop to 0, and putting the cap back
|
|
99
99
|
* does not bring the position back. Every click that changed the content —
|
|
100
100
|
* ticking an item, picking a span — threw the list back to the top.
|
|
101
|
+
*
|
|
102
|
+
* The position to put back is read live just before measuring, and only a
|
|
103
|
+
* list the measurement actually moved is touched, with an instant write. An
|
|
104
|
+
* earlier version replayed the last scroll event's figure through
|
|
105
|
+
* `scrollTop` on every retune. Inside a `scroll-smooth` area that write is
|
|
106
|
+
* itself animated, its in-between frames were recorded as new positions,
|
|
107
|
+
* and any mutation while the user scrolled — a row re-rendering on hover —
|
|
108
|
+
* replayed a stale one: a long dialog crept back to the top and could not
|
|
109
|
+
* be scrolled at all.
|
|
101
110
|
*/
|
|
102
|
-
const scrolled = new
|
|
111
|
+
const scrolled = new Set<Element>()
|
|
103
112
|
const rememberScroll = (event: Event) => {
|
|
104
113
|
const target = event.target
|
|
105
|
-
if (target instanceof Element) scrolled.
|
|
114
|
+
if (target instanceof Element) scrolled.add(target)
|
|
106
115
|
}
|
|
107
|
-
function
|
|
108
|
-
|
|
116
|
+
function readScroll(): Map<Element, number> {
|
|
117
|
+
const tops = new Map<Element, number>()
|
|
118
|
+
for (const node of scrolled) {
|
|
109
119
|
if (!node.isConnected) scrolled.delete(node)
|
|
110
|
-
else
|
|
120
|
+
else tops.set(node, node.scrollTop)
|
|
121
|
+
}
|
|
122
|
+
return tops
|
|
123
|
+
}
|
|
124
|
+
function restoreScroll(tops: Map<Element, number>) {
|
|
125
|
+
for (const [node, top] of tops) {
|
|
126
|
+
if (node.isConnected && node.scrollTop !== top) {
|
|
127
|
+
node.scrollTo({ top, behavior: "instant" })
|
|
128
|
+
}
|
|
111
129
|
}
|
|
112
130
|
}
|
|
113
131
|
|
|
@@ -153,6 +171,7 @@ export function useShift(
|
|
|
153
171
|
// already reflowed, so the element can only report where it is going.
|
|
154
172
|
const fromHeight = lastHeight
|
|
155
173
|
const fromWidth = lastWidth
|
|
174
|
+
const tops = readScroll()
|
|
156
175
|
|
|
157
176
|
// Measured with any cap lifted, then the clamp is left to CSS. A `max-*`
|
|
158
177
|
// from a class makes the element report the clipped figure once the cap
|
|
@@ -170,7 +189,7 @@ export function useShift(
|
|
|
170
189
|
const toWidth = wantsWidth() ? node.offsetWidth : 0
|
|
171
190
|
if (wantsHeight()) node.style.maxHeight = ""
|
|
172
191
|
if (wantsWidth()) node.style.maxWidth = ""
|
|
173
|
-
restoreScroll()
|
|
192
|
+
restoreScroll(tops)
|
|
174
193
|
lastHeight = toHeight
|
|
175
194
|
lastWidth = toWidth
|
|
176
195
|
|
|
@@ -193,7 +212,7 @@ export function useShift(
|
|
|
193
212
|
void node.offsetHeight
|
|
194
213
|
if (movesHeight) node.style.height = `${toHeight}px`
|
|
195
214
|
if (movesWidth) node.style.width = `${toWidth}px`
|
|
196
|
-
restoreScroll()
|
|
215
|
+
restoreScroll(tops)
|
|
197
216
|
|
|
198
217
|
inFlight = true
|
|
199
218
|
tweenStart = performance.now()
|