@leavepulse/ui 0.96.2 → 0.97.0
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/chunks/{LpHints-wsawHZCP.js → LpHints-lGLsGxOx.js} +1 -1
- package/dist/chunks/{LpTour-iZNl_vdy.js → LpTour-DUzpkD8W.js} +7 -1
- package/dist/components/LpHints.vue.js +1 -1
- package/dist/components/LpTour.vue.js +1 -1
- package/dist/composables/scroll.d.ts +6 -1
- package/dist/composables/scroll.js +2 -0
- package/dist/composables/useDragDrop.d.ts +19 -0
- package/dist/composables/useDragDrop.js +241 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -2
- package/package.json +1 -1
- package/src/components/LpTour.vue +11 -1
- package/src/composables/scroll.ts +8 -1
- package/src/composables/useDragDrop.ts +331 -0
- package/src/index.ts +2 -0
|
@@ -4,7 +4,7 @@ import { useAppleKeyboard, appleModifierLabel } from "../composables/useHotkeys.
|
|
|
4
4
|
import { useLabels } from "../composables/useLpConfig.js";
|
|
5
5
|
import { _ as _sfc_main$3 } from "./LpActionBar-DuwBawji.js";
|
|
6
6
|
import { _ as _sfc_main$2 } from "./LpHintLayer-rD3BTMfK.js";
|
|
7
|
-
import { _ as _sfc_main$1 } from "./LpTour-
|
|
7
|
+
import { _ as _sfc_main$1 } from "./LpTour-DUzpkD8W.js";
|
|
8
8
|
const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
9
9
|
__name: "LpHints",
|
|
10
10
|
props: {
|
|
@@ -149,6 +149,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
149
149
|
clearTimeout(glide);
|
|
150
150
|
glide = window.setTimeout(() => gliding.value = false, durationToken("duration-medium", void 0, doc()) + 40);
|
|
151
151
|
}
|
|
152
|
+
let shownRaw = void 0;
|
|
152
153
|
function measure() {
|
|
153
154
|
viewport.value = { width: win().innerWidth, height: win().innerHeight };
|
|
154
155
|
const el = target.value;
|
|
@@ -156,7 +157,11 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
156
157
|
box.value = null;
|
|
157
158
|
return;
|
|
158
159
|
}
|
|
159
|
-
if (!el.isConnected)
|
|
160
|
+
if (!el.isConnected) {
|
|
161
|
+
const again = resolve(shownRaw);
|
|
162
|
+
glideTo(again && again.getClientRects().length ? again : null);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
160
165
|
const r = el.getBoundingClientRect();
|
|
161
166
|
const prev = box.value;
|
|
162
167
|
if (!prev || prev.x !== r.x || prev.y !== r.y || prev.width !== r.width || prev.height !== r.height) {
|
|
@@ -193,6 +198,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
193
198
|
}
|
|
194
199
|
const el = current.enter || sampled.value ? await waitFor(raw, mine) : resolve(raw);
|
|
195
200
|
if (mine !== token) return;
|
|
201
|
+
shownRaw = raw;
|
|
196
202
|
if (!el || !el.getClientRects().length) return skip();
|
|
197
203
|
await settled(el);
|
|
198
204
|
if (mine !== token) return;
|
|
@@ -12,7 +12,12 @@ export declare function scrollParent(el: Element | null | undefined, { axis, sel
|
|
|
12
12
|
*/
|
|
13
13
|
export declare function atScrollEnd(el: Element | null | undefined, slack?: number): boolean;
|
|
14
14
|
export interface ScrollToElementOptions {
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* `nearest` scrolls only when the element is out of view. An element taller
|
|
17
|
+
* than the scroller is brought in by its start whatever the block: centring
|
|
18
|
+
* or meeting its far edge would show its middle or end, and a list that
|
|
19
|
+
* loads more at its end would start loading.
|
|
20
|
+
*/
|
|
16
21
|
block?: "nearest" | "center" | "start";
|
|
17
22
|
behavior?: ScrollBehavior;
|
|
18
23
|
/** Room kept between the element and the scroller's edge. */
|
|
@@ -35,8 +35,10 @@ function scrollToElement(el, { block = "nearest", behavior = "auto", margin = 0,
|
|
|
35
35
|
const lo = x ? inner.left - outer.left : inner.top - outer.top;
|
|
36
36
|
const hi = x ? inner.right - outer.right : inner.bottom - outer.bottom;
|
|
37
37
|
const at = lo + from;
|
|
38
|
+
const fits = size + 2 * margin <= view;
|
|
38
39
|
let target = from;
|
|
39
40
|
if (block === "start") target = at - margin;
|
|
41
|
+
else if (!fits) target = lo < margin || lo > view - margin || block === "center" ? at - margin : from;
|
|
40
42
|
else if (block === "center") target = at - (view - size) / 2;
|
|
41
43
|
else if (lo < margin) target = at - margin;
|
|
42
44
|
else if (hi > -margin) target = at - view + size + margin;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type MaybeRefOrGetter, type Ref } from "vue";
|
|
2
|
+
export interface UseDragDropOptions<I, Z> {
|
|
3
|
+
/** The element that holds the items and the zones. */
|
|
4
|
+
root: Ref<HTMLElement | null>;
|
|
5
|
+
/** Turn dragging off without unmounting (e.g. read-only). */
|
|
6
|
+
enabled?: MaybeRefOrGetter<boolean>;
|
|
7
|
+
/** Map an item's `data-drag-key` to the item. Defaults to the string itself. */
|
|
8
|
+
item?: (raw: string) => I | undefined;
|
|
9
|
+
/** Map a zone's `data-drop-zone` to the zone. Defaults to the string itself. */
|
|
10
|
+
zone?: (raw: string) => Z | undefined;
|
|
11
|
+
/** Whether this item may land in this zone; a refused zone is not highlighted. */
|
|
12
|
+
canDrop?: (item: I, zone: Z) => boolean;
|
|
13
|
+
/** The item landed in a zone other than the one it came from. */
|
|
14
|
+
onDrop: (item: I, zone: Z, from: Z | undefined) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function useDragDrop<I = string, Z = string>(options: UseDragDropOptions<I, Z>): {
|
|
17
|
+
dragging: Readonly<Ref<import("vue").DeepReadonly<I> | null, import("vue").DeepReadonly<I> | null>>;
|
|
18
|
+
over: Readonly<Ref<import("vue").DeepReadonly<Z> | null, import("vue").DeepReadonly<Z> | null>>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { HOLD_TO_DRAG_MS, TOUCH_SLOP, MOUSE_SLOP } from "./gesture.js";
|
|
2
|
+
import { ref, onMounted, onBeforeUnmount, readonly, toValue } from "vue";
|
|
3
|
+
import { prefersReducedMotion, durationToken } from "./easing.js";
|
|
4
|
+
import { scrollParent } from "./scroll.js";
|
|
5
|
+
import { nodeWindow } from "./realm.js";
|
|
6
|
+
const EDGE_PX = 48;
|
|
7
|
+
const SCROLL_STEP = 14;
|
|
8
|
+
const NO_DRAG = "button, a, input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-no-drag]";
|
|
9
|
+
function useDragDrop(options) {
|
|
10
|
+
const toItem = options.item ?? ((raw) => raw);
|
|
11
|
+
const toZone = options.zone ?? ((raw) => raw);
|
|
12
|
+
const enabled = () => toValue(options.enabled) ?? true;
|
|
13
|
+
const hostWindow = () => options.root.value ? nodeWindow(options.root.value) : window;
|
|
14
|
+
let drag = null;
|
|
15
|
+
let pending = null;
|
|
16
|
+
let swallowClick = false;
|
|
17
|
+
let frame = 0;
|
|
18
|
+
const dragging = ref(null);
|
|
19
|
+
const over = ref(null);
|
|
20
|
+
function zoneOf(el) {
|
|
21
|
+
const zone = el?.closest("[data-drop-zone]") ?? null;
|
|
22
|
+
return zone && options.root.value?.contains(zone) ? zone : null;
|
|
23
|
+
}
|
|
24
|
+
function accepts(zoneEl) {
|
|
25
|
+
if (!zoneEl || !drag) return void 0;
|
|
26
|
+
const zone = toZone(zoneEl.dataset.dropZone ?? "");
|
|
27
|
+
if (zone === void 0 || zone === drag.from) return void 0;
|
|
28
|
+
return !options.canDrop || options.canDrop(drag.item, zone) ? zone : void 0;
|
|
29
|
+
}
|
|
30
|
+
function cancelPending() {
|
|
31
|
+
if (pending?.timer) clearTimeout(pending.timer);
|
|
32
|
+
pending = null;
|
|
33
|
+
}
|
|
34
|
+
function begin(el, x, y) {
|
|
35
|
+
const item = toItem(el.dataset.dragKey ?? "");
|
|
36
|
+
if (item === void 0) return;
|
|
37
|
+
const box = el.getBoundingClientRect();
|
|
38
|
+
const ghost = el.cloneNode(true);
|
|
39
|
+
ghost.removeAttribute("data-drag-key");
|
|
40
|
+
ghost.removeAttribute("id");
|
|
41
|
+
ghost.setAttribute("aria-hidden", "true");
|
|
42
|
+
Object.assign(ghost.style, {
|
|
43
|
+
position: "fixed",
|
|
44
|
+
left: `${box.left}px`,
|
|
45
|
+
top: `${box.top}px`,
|
|
46
|
+
width: `${box.width}px`,
|
|
47
|
+
height: `${box.height}px`,
|
|
48
|
+
margin: "0",
|
|
49
|
+
pointerEvents: "none",
|
|
50
|
+
zIndex: "var(--z-tooltip)",
|
|
51
|
+
boxShadow: "var(--shadow-panel)",
|
|
52
|
+
opacity: "0.95",
|
|
53
|
+
transform: "translate3d(0, 0, 0) scale(1.02)",
|
|
54
|
+
transition: "none"
|
|
55
|
+
});
|
|
56
|
+
el.ownerDocument.body.append(ghost);
|
|
57
|
+
const fromZone = zoneOf(el);
|
|
58
|
+
drag = {
|
|
59
|
+
item,
|
|
60
|
+
from: fromZone ? toZone(fromZone.dataset.dropZone ?? "") : void 0,
|
|
61
|
+
source: el,
|
|
62
|
+
ghost,
|
|
63
|
+
dx: x - box.left,
|
|
64
|
+
dy: y - box.top,
|
|
65
|
+
x,
|
|
66
|
+
y,
|
|
67
|
+
over: null,
|
|
68
|
+
scroller: scrollParent(options.root.value)
|
|
69
|
+
};
|
|
70
|
+
el.setAttribute("data-drag-source", "");
|
|
71
|
+
dragging.value = item;
|
|
72
|
+
hostWindow().getSelection()?.removeAllRanges();
|
|
73
|
+
if (options.root.value) options.root.value.style.userSelect = "none";
|
|
74
|
+
follow();
|
|
75
|
+
if (!frame) frame = requestAnimationFrame(tick);
|
|
76
|
+
}
|
|
77
|
+
function follow() {
|
|
78
|
+
if (!drag) return;
|
|
79
|
+
const box = drag.source.getBoundingClientRect();
|
|
80
|
+
drag.ghost.style.left = `${drag.x - drag.dx}px`;
|
|
81
|
+
drag.ghost.style.top = `${drag.y - drag.dy}px`;
|
|
82
|
+
drag.ghost.style.width = `${box.width}px`;
|
|
83
|
+
const zoneEl = zoneOf(drag.source.ownerDocument.elementFromPoint(drag.x, drag.y));
|
|
84
|
+
const zone = accepts(zoneEl);
|
|
85
|
+
const target = zone === void 0 ? null : zoneEl;
|
|
86
|
+
if (target !== drag.over) {
|
|
87
|
+
drag.over?.removeAttribute("data-drop-over");
|
|
88
|
+
target?.setAttribute("data-drop-over", "");
|
|
89
|
+
drag.over = target;
|
|
90
|
+
over.value = zone ?? null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function tick() {
|
|
94
|
+
frame = 0;
|
|
95
|
+
if (!drag) return;
|
|
96
|
+
const box = drag.scroller?.getBoundingClientRect() ?? { top: 0, bottom: hostWindow().innerHeight };
|
|
97
|
+
const delta = drag.y > box.bottom - EDGE_PX ? SCROLL_STEP : drag.y < box.top + EDGE_PX ? -SCROLL_STEP : 0;
|
|
98
|
+
if (delta) {
|
|
99
|
+
if (drag.scroller) drag.scroller.scrollTop += delta;
|
|
100
|
+
else hostWindow().scrollBy(0, delta);
|
|
101
|
+
follow();
|
|
102
|
+
}
|
|
103
|
+
frame = requestAnimationFrame(tick);
|
|
104
|
+
}
|
|
105
|
+
function finish(drop) {
|
|
106
|
+
cancelPending();
|
|
107
|
+
const done = drag;
|
|
108
|
+
if (!done) return;
|
|
109
|
+
drag = null;
|
|
110
|
+
if (frame) cancelAnimationFrame(frame);
|
|
111
|
+
frame = 0;
|
|
112
|
+
if (options.root.value) options.root.value.style.userSelect = "";
|
|
113
|
+
done.over?.removeAttribute("data-drop-over");
|
|
114
|
+
done.source.removeAttribute("data-drag-source");
|
|
115
|
+
dragging.value = null;
|
|
116
|
+
over.value = null;
|
|
117
|
+
swallowClick = true;
|
|
118
|
+
setTimeout(() => swallowClick = false, 0);
|
|
119
|
+
const zone = drop && done.over ? toZone(done.over.dataset.dropZone ?? "") : void 0;
|
|
120
|
+
if (zone !== void 0) {
|
|
121
|
+
done.ghost.remove();
|
|
122
|
+
options.onDrop(done.item, zone, done.from);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
settle(done);
|
|
126
|
+
}
|
|
127
|
+
function settle(done) {
|
|
128
|
+
if (prefersReducedMotion(hostWindow()) || !done.source.isConnected) {
|
|
129
|
+
done.ghost.remove();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const home = done.source.getBoundingClientRect();
|
|
133
|
+
const ghost = done.ghost;
|
|
134
|
+
const ms = durationToken("duration-medium", void 0, done.source.ownerDocument);
|
|
135
|
+
ghost.style.transition = `left ${ms}ms var(--ease-emphasized), top ${ms}ms var(--ease-emphasized), opacity ${ms}ms ease-out`;
|
|
136
|
+
ghost.style.left = `${home.left}px`;
|
|
137
|
+
ghost.style.top = `${home.top}px`;
|
|
138
|
+
ghost.style.opacity = "0";
|
|
139
|
+
setTimeout(() => ghost.remove(), ms);
|
|
140
|
+
}
|
|
141
|
+
function onPointerDown(event) {
|
|
142
|
+
if (!enabled() || event.button !== 0 || drag) return;
|
|
143
|
+
const target = event.target;
|
|
144
|
+
const el = target?.closest("[data-drag-key]");
|
|
145
|
+
if (!el || !options.root.value?.contains(el)) return;
|
|
146
|
+
const control = target?.closest(NO_DRAG);
|
|
147
|
+
if (control && el.contains(control)) return;
|
|
148
|
+
cancelPending();
|
|
149
|
+
const touch = event.pointerType === "touch";
|
|
150
|
+
pending = { el, pointerId: event.pointerId, x: event.clientX, y: event.clientY, touch, timer: null };
|
|
151
|
+
if (!touch) return;
|
|
152
|
+
const { clientX: x, clientY: y } = event;
|
|
153
|
+
pending.timer = setTimeout(() => {
|
|
154
|
+
if (!pending) return;
|
|
155
|
+
const held = pending.el;
|
|
156
|
+
pending = null;
|
|
157
|
+
begin(held, x, y);
|
|
158
|
+
navigator.vibrate?.(8);
|
|
159
|
+
}, HOLD_TO_DRAG_MS);
|
|
160
|
+
}
|
|
161
|
+
function onMove(event) {
|
|
162
|
+
if (pending && event.pointerId === pending.pointerId) {
|
|
163
|
+
const moved = Math.hypot(event.clientX - pending.x, event.clientY - pending.y);
|
|
164
|
+
if (pending.touch) {
|
|
165
|
+
if (moved > TOUCH_SLOP) cancelPending();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (moved <= MOUSE_SLOP) return;
|
|
169
|
+
const { el, x, y } = pending;
|
|
170
|
+
pending = null;
|
|
171
|
+
begin(el, x, y);
|
|
172
|
+
}
|
|
173
|
+
if (!drag) return;
|
|
174
|
+
drag.x = event.clientX;
|
|
175
|
+
drag.y = event.clientY;
|
|
176
|
+
follow();
|
|
177
|
+
}
|
|
178
|
+
function onUp() {
|
|
179
|
+
cancelPending();
|
|
180
|
+
finish(true);
|
|
181
|
+
}
|
|
182
|
+
function onCancel() {
|
|
183
|
+
cancelPending();
|
|
184
|
+
finish(false);
|
|
185
|
+
}
|
|
186
|
+
function onKey(event) {
|
|
187
|
+
if (event.key !== "Escape" || !drag) return;
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
event.stopPropagation();
|
|
190
|
+
finish(false);
|
|
191
|
+
}
|
|
192
|
+
function onClickCapture(event) {
|
|
193
|
+
if (!swallowClick) return;
|
|
194
|
+
swallowClick = false;
|
|
195
|
+
event.stopPropagation();
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
}
|
|
198
|
+
function onContextMenu(event) {
|
|
199
|
+
if (!drag && !swallowClick) return;
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
event.stopPropagation();
|
|
202
|
+
}
|
|
203
|
+
function onTouchMove(event) {
|
|
204
|
+
if (drag) event.preventDefault();
|
|
205
|
+
}
|
|
206
|
+
function onNativeDrag(event) {
|
|
207
|
+
if (pending || drag) event.preventDefault();
|
|
208
|
+
}
|
|
209
|
+
let listening = null;
|
|
210
|
+
let rootEl = null;
|
|
211
|
+
onMounted(() => {
|
|
212
|
+
listening = hostWindow();
|
|
213
|
+
rootEl = options.root.value;
|
|
214
|
+
rootEl?.addEventListener("pointerdown", onPointerDown);
|
|
215
|
+
rootEl?.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
216
|
+
rootEl?.addEventListener("dragstart", onNativeDrag);
|
|
217
|
+
listening.addEventListener("pointermove", onMove);
|
|
218
|
+
listening.addEventListener("pointerup", onUp);
|
|
219
|
+
listening.addEventListener("pointercancel", onCancel);
|
|
220
|
+
listening.addEventListener("keydown", onKey, true);
|
|
221
|
+
listening.addEventListener("click", onClickCapture, true);
|
|
222
|
+
listening.addEventListener("contextmenu", onContextMenu, true);
|
|
223
|
+
});
|
|
224
|
+
onBeforeUnmount(() => {
|
|
225
|
+
finish(false);
|
|
226
|
+
swallowClick = false;
|
|
227
|
+
rootEl?.removeEventListener("pointerdown", onPointerDown);
|
|
228
|
+
rootEl?.removeEventListener("touchmove", onTouchMove);
|
|
229
|
+
rootEl?.removeEventListener("dragstart", onNativeDrag);
|
|
230
|
+
listening?.removeEventListener("pointermove", onMove);
|
|
231
|
+
listening?.removeEventListener("pointerup", onUp);
|
|
232
|
+
listening?.removeEventListener("pointercancel", onCancel);
|
|
233
|
+
listening?.removeEventListener("keydown", onKey, true);
|
|
234
|
+
listening?.removeEventListener("click", onClickCapture, true);
|
|
235
|
+
listening?.removeEventListener("contextmenu", onContextMenu, true);
|
|
236
|
+
});
|
|
237
|
+
return { dragging: readonly(dragging), over: readonly(over) };
|
|
238
|
+
}
|
|
239
|
+
export {
|
|
240
|
+
useDragDrop
|
|
241
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -297,6 +297,8 @@ export type { PersistedOptions } from "./composables/usePersisted";
|
|
|
297
297
|
export { useMergedClass, useMergedAttrs, mergeClass } from "./composables/useMergedClass";
|
|
298
298
|
export { anchorPattern, stripDisallowed, useInputFilter } from "./composables/useInputFilter";
|
|
299
299
|
export { usePaintSelect } from "./composables/usePaintSelect";
|
|
300
|
+
export { useDragDrop } from "./composables/useDragDrop";
|
|
301
|
+
export type { UseDragDropOptions } from "./composables/useDragDrop";
|
|
300
302
|
export type { UsePaintSelectOptions } from "./composables/usePaintSelect";
|
|
301
303
|
export { LP_FIELD, fieldMessageId, provideField, useField, useFieldControl } from "./composables/useField";
|
|
302
304
|
export { FIELD_NOTICE_MS, useFieldNotice } from "./composables/useFieldNotice";
|
package/dist/index.js
CHANGED
|
@@ -127,10 +127,10 @@ import { _ as _83 } from "./chunks/LpThemeSwitcher-CnkIH76z.js";
|
|
|
127
127
|
import { _ as _84 } from "./chunks/LpTilt-B7KHX1zH.js";
|
|
128
128
|
import { _ as _85 } from "./chunks/LpToaster-CGHXxCie.js";
|
|
129
129
|
import { _ as _86 } from "./chunks/LpTooltip-CzIfznts.js";
|
|
130
|
-
import { _ as _87 } from "./chunks/LpTour-
|
|
130
|
+
import { _ as _87 } from "./chunks/LpTour-DUzpkD8W.js";
|
|
131
131
|
import { holeOf, openContextMenu, panelsAround } from "./components/tour.js";
|
|
132
132
|
import { _ as _88 } from "./chunks/LpHintLayer-rD3BTMfK.js";
|
|
133
|
-
import { _ as _89 } from "./chunks/LpHints-
|
|
133
|
+
import { _ as _89 } from "./chunks/LpHints-lGLsGxOx.js";
|
|
134
134
|
import { HINT_SIGNALS, contextMenuTip, createHintSystem, dropzoneTip, hintKey, hintSummary, hotkeyTip, localHintStorage, noteDropzone, noteHotkeyTarget, paletteTip, provideHintSystem, runningHintSystem, useHintSystem } from "./composables/useHintSystem.js";
|
|
135
135
|
import { _ as _90 } from "./chunks/LpTooltipProvider-CftjV7Xc.js";
|
|
136
136
|
import { _ as _91 } from "./chunks/LpTopologyCanvas-DKn0Xcz4.js";
|
|
@@ -166,6 +166,7 @@ import { useOptimisticToggle } from "./composables/useOptimisticToggle.js";
|
|
|
166
166
|
import { mergeClass, useMergedAttrs, useMergedClass } from "./composables/useMergedClass.js";
|
|
167
167
|
import { anchorPattern, stripDisallowed, useInputFilter } from "./composables/useInputFilter.js";
|
|
168
168
|
import { usePaintSelect } from "./composables/usePaintSelect.js";
|
|
169
|
+
import { useDragDrop } from "./composables/useDragDrop.js";
|
|
169
170
|
import { LP_FIELD, fieldMessageId, provideField, useField, useFieldControl } from "./composables/useField.js";
|
|
170
171
|
import { FIELD_NOTICE_MS, useFieldNotice } from "./composables/useFieldNotice.js";
|
|
171
172
|
import { easeOut, easeTravel, easingToken, prefersReducedMotion } from "./composables/easing.js";
|
|
@@ -1316,6 +1317,7 @@ export {
|
|
|
1316
1317
|
useCloseGuard,
|
|
1317
1318
|
useCommands,
|
|
1318
1319
|
useConfirm,
|
|
1320
|
+
useDragDrop,
|
|
1319
1321
|
useField,
|
|
1320
1322
|
useFieldControl,
|
|
1321
1323
|
useFieldNotice,
|
package/package.json
CHANGED
|
@@ -256,6 +256,11 @@ function glideTo(el: Element | null) {
|
|
|
256
256
|
glide = window.setTimeout(() => (gliding.value = false), durationToken("duration-medium", undefined, doc()) + 40)
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// What the shown step points at, kept to find it again: a list that re-renders
|
|
260
|
+
// replaces the element, and a card anchored to the old one would hang over
|
|
261
|
+
// nothing (or nowhere) with the page dimmed under it.
|
|
262
|
+
let shownRaw: TourStep["target"] = undefined
|
|
263
|
+
|
|
259
264
|
function measure() {
|
|
260
265
|
viewport.value = { width: win().innerWidth, height: win().innerHeight }
|
|
261
266
|
const el = target.value
|
|
@@ -263,7 +268,11 @@ function measure() {
|
|
|
263
268
|
box.value = null
|
|
264
269
|
return
|
|
265
270
|
}
|
|
266
|
-
if (!el.isConnected)
|
|
271
|
+
if (!el.isConnected) {
|
|
272
|
+
const again = resolve(shownRaw)
|
|
273
|
+
glideTo(again && again.getClientRects().length ? again : null)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
267
276
|
const r = el.getBoundingClientRect()
|
|
268
277
|
const prev = box.value
|
|
269
278
|
if (!prev || prev.x !== r.x || prev.y !== r.y || prev.width !== r.width || prev.height !== r.height) {
|
|
@@ -302,6 +311,7 @@ async function show(): Promise<void> {
|
|
|
302
311
|
}
|
|
303
312
|
const el = current.enter || sampled.value ? await waitFor(raw, mine) : resolve(raw)
|
|
304
313
|
if (mine !== token) return
|
|
314
|
+
shownRaw = raw
|
|
305
315
|
if (!el || !el.getClientRects().length) return skip()
|
|
306
316
|
await settled(el)
|
|
307
317
|
if (mine !== token) return
|
|
@@ -40,7 +40,12 @@ export function atScrollEnd(el: Element | null | undefined, slack = 2): boolean
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface ScrollToElementOptions {
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* `nearest` scrolls only when the element is out of view. An element taller
|
|
45
|
+
* than the scroller is brought in by its start whatever the block: centring
|
|
46
|
+
* or meeting its far edge would show its middle or end, and a list that
|
|
47
|
+
* loads more at its end would start loading.
|
|
48
|
+
*/
|
|
44
49
|
block?: "nearest" | "center" | "start"
|
|
45
50
|
behavior?: ScrollBehavior
|
|
46
51
|
/** Room kept between the element and the scroller's edge. */
|
|
@@ -76,8 +81,10 @@ export function scrollToElement(
|
|
|
76
81
|
const lo = x ? inner.left - outer.left : inner.top - outer.top
|
|
77
82
|
const hi = x ? inner.right - outer.right : inner.bottom - outer.bottom
|
|
78
83
|
const at = lo + from
|
|
84
|
+
const fits = size + 2 * margin <= view
|
|
79
85
|
let target = from
|
|
80
86
|
if (block === "start") target = at - margin
|
|
87
|
+
else if (!fits) target = lo < margin || lo > view - margin || block === "center" ? at - margin : from
|
|
81
88
|
else if (block === "center") target = at - (view - size) / 2
|
|
82
89
|
else if (lo < margin) target = at - margin
|
|
83
90
|
else if (hi > -margin) target = at - view + size + margin
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { HOLD_TO_DRAG_MS, MOUSE_SLOP, TOUCH_SLOP } from "./gesture"
|
|
2
|
+
import { onBeforeUnmount, onMounted, readonly, ref, toValue, type MaybeRefOrGetter, type Ref } from "vue"
|
|
3
|
+
import { durationToken, prefersReducedMotion } from "./easing"
|
|
4
|
+
import { scrollParent } from "./scroll"
|
|
5
|
+
import { nodeWindow } from "./useView"
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Moving things between places by dragging them: a person from one team to
|
|
9
|
+
* another, a card to another column, a row back to the pool. The page marks
|
|
10
|
+
* what can be picked up with `data-drag-key` and where it can land with
|
|
11
|
+
* `data-drop-zone`; this composable does the rest and reports a drop as
|
|
12
|
+
* (item, zone, from).
|
|
13
|
+
*
|
|
14
|
+
* const dnd = useDragDrop({ root, onDrop: (item, zone) => move(item, zone) })
|
|
15
|
+
* <div ref="root">
|
|
16
|
+
* <section data-drop-zone="team-a"><div data-drag-key="u1">Ada</div></section>
|
|
17
|
+
* <section data-drop-zone="pool" />
|
|
18
|
+
* </div>
|
|
19
|
+
*
|
|
20
|
+
* A mouse or pen starts dragging once it moves a few pixels, so a click on the
|
|
21
|
+
* item stays a click. A finger holds still for HOLD_TO_DRAG_MS first, so a
|
|
22
|
+
* swipe through the list still scrolls it, and the long press does not open
|
|
23
|
+
* the context menu once it has turned into a drag. Controls inside an item
|
|
24
|
+
* (buttons, links, fields, `data-no-drag`) never start one.
|
|
25
|
+
*
|
|
26
|
+
* While dragging, a copy of the item follows the pointer, the item itself
|
|
27
|
+
* carries `data-drag-source` and the zone under the pointer `data-drop-over`,
|
|
28
|
+
* for styling (`data-[drop-over]:border-brand`). Near the edge of the
|
|
29
|
+
* scrolling box the page scrolls on its own. A drop on the zone the item came
|
|
30
|
+
* from, on a zone `canDrop` refuses or outside every zone is no drop: the copy
|
|
31
|
+
* glides back. Escape cancels.
|
|
32
|
+
*
|
|
33
|
+
* Dragging is a shortcut, never the only way: whatever a drop does should also
|
|
34
|
+
* be reachable from a menu or a button, for the keyboard and screen readers.
|
|
35
|
+
*/
|
|
36
|
+
export interface UseDragDropOptions<I, Z> {
|
|
37
|
+
/** The element that holds the items and the zones. */
|
|
38
|
+
root: Ref<HTMLElement | null>
|
|
39
|
+
/** Turn dragging off without unmounting (e.g. read-only). */
|
|
40
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
41
|
+
/** Map an item's `data-drag-key` to the item. Defaults to the string itself. */
|
|
42
|
+
item?: (raw: string) => I | undefined
|
|
43
|
+
/** Map a zone's `data-drop-zone` to the zone. Defaults to the string itself. */
|
|
44
|
+
zone?: (raw: string) => Z | undefined
|
|
45
|
+
/** Whether this item may land in this zone; a refused zone is not highlighted. */
|
|
46
|
+
canDrop?: (item: I, zone: Z) => boolean
|
|
47
|
+
/** The item landed in a zone other than the one it came from. */
|
|
48
|
+
onDrop: (item: I, zone: Z, from: Z | undefined) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const EDGE_PX = 48
|
|
52
|
+
const SCROLL_STEP = 14
|
|
53
|
+
const NO_DRAG = "button, a, input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-no-drag]"
|
|
54
|
+
|
|
55
|
+
interface Drag<I, Z> {
|
|
56
|
+
item: I
|
|
57
|
+
from: Z | undefined
|
|
58
|
+
source: HTMLElement
|
|
59
|
+
ghost: HTMLElement
|
|
60
|
+
dx: number
|
|
61
|
+
dy: number
|
|
62
|
+
x: number
|
|
63
|
+
y: number
|
|
64
|
+
over: HTMLElement | null
|
|
65
|
+
scroller: HTMLElement | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Pending {
|
|
69
|
+
el: HTMLElement
|
|
70
|
+
pointerId: number
|
|
71
|
+
x: number
|
|
72
|
+
y: number
|
|
73
|
+
touch: boolean
|
|
74
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useDragDrop<I = string, Z = string>(options: UseDragDropOptions<I, Z>) {
|
|
78
|
+
const toItem = options.item ?? ((raw: string) => raw as unknown as I)
|
|
79
|
+
const toZone = options.zone ?? ((raw: string) => raw as unknown as Z)
|
|
80
|
+
const enabled = () => toValue(options.enabled) ?? true
|
|
81
|
+
const hostWindow = (): Window => (options.root.value ? nodeWindow(options.root.value) : window)
|
|
82
|
+
|
|
83
|
+
let drag: Drag<I, Z> | null = null
|
|
84
|
+
let pending: Pending | null = null
|
|
85
|
+
let swallowClick = false
|
|
86
|
+
let frame = 0
|
|
87
|
+
const dragging = ref<I | null>(null) as Ref<I | null>
|
|
88
|
+
const over = ref<Z | null>(null) as Ref<Z | null>
|
|
89
|
+
|
|
90
|
+
function zoneOf(el: Element | null): HTMLElement | null {
|
|
91
|
+
const zone = el?.closest<HTMLElement>("[data-drop-zone]") ?? null
|
|
92
|
+
return zone && options.root.value?.contains(zone) ? zone : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function accepts(zoneEl: HTMLElement | null): Z | undefined {
|
|
96
|
+
if (!zoneEl || !drag) return undefined
|
|
97
|
+
const zone = toZone(zoneEl.dataset.dropZone ?? "")
|
|
98
|
+
if (zone === undefined || zone === drag.from) return undefined
|
|
99
|
+
return !options.canDrop || options.canDrop(drag.item, zone) ? zone : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cancelPending() {
|
|
103
|
+
if (pending?.timer) clearTimeout(pending.timer)
|
|
104
|
+
pending = null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function begin(el: HTMLElement, x: number, y: number) {
|
|
108
|
+
const item = toItem(el.dataset.dragKey ?? "")
|
|
109
|
+
if (item === undefined) return
|
|
110
|
+
const box = el.getBoundingClientRect()
|
|
111
|
+
const ghost = el.cloneNode(true) as HTMLElement
|
|
112
|
+
ghost.removeAttribute("data-drag-key")
|
|
113
|
+
ghost.removeAttribute("id")
|
|
114
|
+
ghost.setAttribute("aria-hidden", "true")
|
|
115
|
+
Object.assign(ghost.style, {
|
|
116
|
+
position: "fixed",
|
|
117
|
+
left: `${box.left}px`,
|
|
118
|
+
top: `${box.top}px`,
|
|
119
|
+
width: `${box.width}px`,
|
|
120
|
+
height: `${box.height}px`,
|
|
121
|
+
margin: "0",
|
|
122
|
+
pointerEvents: "none",
|
|
123
|
+
zIndex: "var(--z-tooltip)",
|
|
124
|
+
boxShadow: "var(--shadow-panel)",
|
|
125
|
+
opacity: "0.95",
|
|
126
|
+
transform: "translate3d(0, 0, 0) scale(1.02)",
|
|
127
|
+
transition: "none",
|
|
128
|
+
})
|
|
129
|
+
el.ownerDocument.body.append(ghost)
|
|
130
|
+
const fromZone = zoneOf(el)
|
|
131
|
+
drag = {
|
|
132
|
+
item,
|
|
133
|
+
from: fromZone ? toZone(fromZone.dataset.dropZone ?? "") : undefined,
|
|
134
|
+
source: el,
|
|
135
|
+
ghost,
|
|
136
|
+
dx: x - box.left,
|
|
137
|
+
dy: y - box.top,
|
|
138
|
+
x,
|
|
139
|
+
y,
|
|
140
|
+
over: null,
|
|
141
|
+
scroller: scrollParent(options.root.value),
|
|
142
|
+
}
|
|
143
|
+
el.setAttribute("data-drag-source", "")
|
|
144
|
+
dragging.value = item
|
|
145
|
+
hostWindow().getSelection()?.removeAllRanges()
|
|
146
|
+
if (options.root.value) options.root.value.style.userSelect = "none"
|
|
147
|
+
follow()
|
|
148
|
+
if (!frame) frame = requestAnimationFrame(tick)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function follow() {
|
|
152
|
+
if (!drag) return
|
|
153
|
+
const box = drag.source.getBoundingClientRect()
|
|
154
|
+
drag.ghost.style.left = `${drag.x - drag.dx}px`
|
|
155
|
+
drag.ghost.style.top = `${drag.y - drag.dy}px`
|
|
156
|
+
drag.ghost.style.width = `${box.width}px`
|
|
157
|
+
const zoneEl = zoneOf(drag.source.ownerDocument.elementFromPoint(drag.x, drag.y))
|
|
158
|
+
const zone = accepts(zoneEl)
|
|
159
|
+
const target = zone === undefined ? null : zoneEl
|
|
160
|
+
if (target !== drag.over) {
|
|
161
|
+
drag.over?.removeAttribute("data-drop-over")
|
|
162
|
+
target?.setAttribute("data-drop-over", "")
|
|
163
|
+
drag.over = target
|
|
164
|
+
over.value = zone ?? null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function tick() {
|
|
169
|
+
frame = 0
|
|
170
|
+
if (!drag) return
|
|
171
|
+
const box = drag.scroller?.getBoundingClientRect() ?? { top: 0, bottom: hostWindow().innerHeight }
|
|
172
|
+
const delta = drag.y > box.bottom - EDGE_PX ? SCROLL_STEP : drag.y < box.top + EDGE_PX ? -SCROLL_STEP : 0
|
|
173
|
+
if (delta) {
|
|
174
|
+
if (drag.scroller) drag.scroller.scrollTop += delta
|
|
175
|
+
else hostWindow().scrollBy(0, delta)
|
|
176
|
+
follow()
|
|
177
|
+
}
|
|
178
|
+
frame = requestAnimationFrame(tick)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function finish(drop: boolean) {
|
|
182
|
+
cancelPending()
|
|
183
|
+
const done = drag
|
|
184
|
+
if (!done) return
|
|
185
|
+
drag = null
|
|
186
|
+
if (frame) cancelAnimationFrame(frame)
|
|
187
|
+
frame = 0
|
|
188
|
+
if (options.root.value) options.root.value.style.userSelect = ""
|
|
189
|
+
done.over?.removeAttribute("data-drop-over")
|
|
190
|
+
done.source.removeAttribute("data-drag-source")
|
|
191
|
+
dragging.value = null
|
|
192
|
+
over.value = null
|
|
193
|
+
swallowClick = true
|
|
194
|
+
setTimeout(() => (swallowClick = false), 0)
|
|
195
|
+
const zone = drop && done.over ? toZone(done.over.dataset.dropZone ?? "") : undefined
|
|
196
|
+
if (zone !== undefined) {
|
|
197
|
+
done.ghost.remove()
|
|
198
|
+
options.onDrop(done.item, zone, done.from)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
settle(done)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A drag that landed nowhere glides back to where the item still is.
|
|
205
|
+
function settle(done: Drag<I, Z>) {
|
|
206
|
+
if (prefersReducedMotion(hostWindow()) || !done.source.isConnected) {
|
|
207
|
+
done.ghost.remove()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
const home = done.source.getBoundingClientRect()
|
|
211
|
+
const ghost = done.ghost
|
|
212
|
+
const ms = durationToken("duration-medium", undefined, done.source.ownerDocument)
|
|
213
|
+
ghost.style.transition = `left ${ms}ms var(--ease-emphasized), top ${ms}ms var(--ease-emphasized), opacity ${ms}ms ease-out`
|
|
214
|
+
ghost.style.left = `${home.left}px`
|
|
215
|
+
ghost.style.top = `${home.top}px`
|
|
216
|
+
ghost.style.opacity = "0"
|
|
217
|
+
setTimeout(() => ghost.remove(), ms)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function onPointerDown(event: PointerEvent) {
|
|
221
|
+
if (!enabled() || event.button !== 0 || drag) return
|
|
222
|
+
const target = event.target as Element | null
|
|
223
|
+
const el = target?.closest<HTMLElement>("[data-drag-key]")
|
|
224
|
+
if (!el || !options.root.value?.contains(el)) return
|
|
225
|
+
const control = target?.closest(NO_DRAG)
|
|
226
|
+
if (control && el.contains(control)) return
|
|
227
|
+
cancelPending()
|
|
228
|
+
const touch = event.pointerType === "touch"
|
|
229
|
+
pending = { el, pointerId: event.pointerId, x: event.clientX, y: event.clientY, touch, timer: null }
|
|
230
|
+
if (!touch) return
|
|
231
|
+
const { clientX: x, clientY: y } = event
|
|
232
|
+
pending.timer = setTimeout(() => {
|
|
233
|
+
if (!pending) return
|
|
234
|
+
const held = pending.el
|
|
235
|
+
pending = null
|
|
236
|
+
begin(held, x, y)
|
|
237
|
+
navigator.vibrate?.(8)
|
|
238
|
+
}, HOLD_TO_DRAG_MS)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function onMove(event: PointerEvent) {
|
|
242
|
+
if (pending && event.pointerId === pending.pointerId) {
|
|
243
|
+
const moved = Math.hypot(event.clientX - pending.x, event.clientY - pending.y)
|
|
244
|
+
if (pending.touch) {
|
|
245
|
+
if (moved > TOUCH_SLOP) cancelPending()
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (moved <= MOUSE_SLOP) return
|
|
249
|
+
const { el, x, y } = pending
|
|
250
|
+
pending = null
|
|
251
|
+
begin(el, x, y)
|
|
252
|
+
}
|
|
253
|
+
if (!drag) return
|
|
254
|
+
drag.x = event.clientX
|
|
255
|
+
drag.y = event.clientY
|
|
256
|
+
follow()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function onUp() {
|
|
260
|
+
cancelPending()
|
|
261
|
+
finish(true)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function onCancel() {
|
|
265
|
+
cancelPending()
|
|
266
|
+
finish(false)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function onKey(event: KeyboardEvent) {
|
|
270
|
+
if (event.key !== "Escape" || !drag) return
|
|
271
|
+
event.preventDefault()
|
|
272
|
+
event.stopPropagation()
|
|
273
|
+
finish(false)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function onClickCapture(event: MouseEvent) {
|
|
277
|
+
if (!swallowClick) return
|
|
278
|
+
swallowClick = false
|
|
279
|
+
event.stopPropagation()
|
|
280
|
+
event.preventDefault()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A long press is also how a finger asks for the context menu; once it has
|
|
284
|
+
// turned into a drag, that menu would open over it.
|
|
285
|
+
function onContextMenu(event: Event) {
|
|
286
|
+
if (!drag && !swallowClick) return
|
|
287
|
+
event.preventDefault()
|
|
288
|
+
event.stopPropagation()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// A finger that has started dragging must not scroll the page under it.
|
|
292
|
+
function onTouchMove(event: TouchEvent) {
|
|
293
|
+
if (drag) event.preventDefault()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The browser's own drag of a link or an image would take over the pointer.
|
|
297
|
+
function onNativeDrag(event: DragEvent) {
|
|
298
|
+
if (pending || drag) event.preventDefault()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let listening: Window | null = null
|
|
302
|
+
let rootEl: HTMLElement | null = null
|
|
303
|
+
onMounted(() => {
|
|
304
|
+
listening = hostWindow()
|
|
305
|
+
rootEl = options.root.value
|
|
306
|
+
rootEl?.addEventListener("pointerdown", onPointerDown)
|
|
307
|
+
rootEl?.addEventListener("touchmove", onTouchMove, { passive: false })
|
|
308
|
+
rootEl?.addEventListener("dragstart", onNativeDrag)
|
|
309
|
+
listening.addEventListener("pointermove", onMove)
|
|
310
|
+
listening.addEventListener("pointerup", onUp)
|
|
311
|
+
listening.addEventListener("pointercancel", onCancel)
|
|
312
|
+
listening.addEventListener("keydown", onKey, true)
|
|
313
|
+
listening.addEventListener("click", onClickCapture, true)
|
|
314
|
+
listening.addEventListener("contextmenu", onContextMenu, true)
|
|
315
|
+
})
|
|
316
|
+
onBeforeUnmount(() => {
|
|
317
|
+
finish(false)
|
|
318
|
+
swallowClick = false
|
|
319
|
+
rootEl?.removeEventListener("pointerdown", onPointerDown)
|
|
320
|
+
rootEl?.removeEventListener("touchmove", onTouchMove)
|
|
321
|
+
rootEl?.removeEventListener("dragstart", onNativeDrag)
|
|
322
|
+
listening?.removeEventListener("pointermove", onMove)
|
|
323
|
+
listening?.removeEventListener("pointerup", onUp)
|
|
324
|
+
listening?.removeEventListener("pointercancel", onCancel)
|
|
325
|
+
listening?.removeEventListener("keydown", onKey, true)
|
|
326
|
+
listening?.removeEventListener("click", onClickCapture, true)
|
|
327
|
+
listening?.removeEventListener("contextmenu", onContextMenu, true)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
return { dragging: readonly(dragging), over: readonly(over) }
|
|
331
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -459,6 +459,8 @@ export type { PersistedOptions } from "./composables/usePersisted"
|
|
|
459
459
|
export { useMergedClass, useMergedAttrs, mergeClass } from "./composables/useMergedClass"
|
|
460
460
|
export { anchorPattern, stripDisallowed, useInputFilter } from "./composables/useInputFilter"
|
|
461
461
|
export { usePaintSelect } from "./composables/usePaintSelect"
|
|
462
|
+
export { useDragDrop } from "./composables/useDragDrop"
|
|
463
|
+
export type { UseDragDropOptions } from "./composables/useDragDrop"
|
|
462
464
|
export type { UsePaintSelectOptions } from "./composables/usePaintSelect"
|
|
463
465
|
export { LP_FIELD, fieldMessageId, provideField, useField, useFieldControl } from "./composables/useField"
|
|
464
466
|
export { FIELD_NOTICE_MS, useFieldNotice } from "./composables/useFieldNotice"
|