@capsuletech/web-dnd 0.1.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.
@@ -0,0 +1,14 @@
1
+ import { Accessor } from 'solid-js';
2
+ import { IPoint } from './types';
3
+ interface IAutoScrollConfig {
4
+ /** Зона у края (px), внутри которой начинается скролл. */
5
+ edge?: number;
6
+ /** Максимальная скорость скролла (px/frame). */
7
+ maxSpeed?: number;
8
+ }
9
+ /**
10
+ * Скроллит окно когда указатель приближается к краю viewport'а.
11
+ * Активируется через `<DnDProvider autoScroll>` — вне drag'а ничего не делает.
12
+ */
13
+ export declare const createWindowAutoScroll: (pointer: Accessor<IPoint | null>, isActive: Accessor<boolean>, config?: IAutoScrollConfig) => void;
14
+ export {};
@@ -0,0 +1,17 @@
1
+ import { Accessor, Component } from 'solid-js';
2
+ import { DragData, DraggableId, DroppableId, IDnDProviderProps, IDraggableEntry, IDroppableEntry, IPoint } from './types';
3
+ interface IDnDContext {
4
+ state: {
5
+ activeId: Accessor<DraggableId | null>;
6
+ activeData: Accessor<DragData | null>;
7
+ pointer: Accessor<IPoint | null>;
8
+ overId: Accessor<DroppableId | null>;
9
+ canDrop: Accessor<boolean>;
10
+ };
11
+ registerDraggable: (entry: IDraggableEntry) => () => void;
12
+ registerDroppable: (entry: IDroppableEntry) => () => void;
13
+ startDrag: (id: DraggableId, e: PointerEvent) => void;
14
+ }
15
+ export declare const useDnD: () => IDnDContext;
16
+ export declare const DnDProvider: Component<IDnDProviderProps>;
17
+ export {};
@@ -0,0 +1,13 @@
1
+ import { DragData, IDraggable, IDraggableOptions } from './types';
2
+ /**
3
+ * Primitive для draggable-источника. Применяется как `ref={drag.ref}` на
4
+ * элементе, который должен быть перетаскиваемым.
5
+ *
6
+ * Старт drag'а — `pointerdown` (любая кнопка/палец). На draggable-элементе
7
+ * проставляется `touch-action: none`, чтобы touch-drag не конфликтовал со
8
+ * скроллом страницы.
9
+ *
10
+ * Текстовое выделение во время drag'а гасится глобально через `user-select:
11
+ * none` пока `isDragging`.
12
+ */
13
+ export declare const createDraggable: <T extends DragData = DragData>(options: IDraggableOptions<T>) => IDraggable;
@@ -0,0 +1,12 @@
1
+ import { DragData, IDroppable, IDroppableOptions } from './types';
2
+ /**
3
+ * Primitive для drop-цели. `ref={drop.ref}` на элементе.
4
+ *
5
+ * `accepts(data)` вызывается на каждом move/up для решения «можно ли сюда».
6
+ * Возвращаемые сигналы:
7
+ * - `isOver` — pointer находится над этим droppable;
8
+ * - `canDrop` — `isOver && accepts(activeData)`.
9
+ *
10
+ * Если `disabled()` true, droppable считается незарегистрированным.
11
+ */
12
+ export declare const createDroppable: <T extends DragData = DragData>(options: IDroppableOptions<T>) => IDroppable;
@@ -0,0 +1,7 @@
1
+ export { DnDProvider, useDnD } from './context';
2
+ export { createDraggable } from './draggable';
3
+ export { createDroppable } from './droppable';
4
+ export { createSortable, isFromSortable } from './sortable';
5
+ export { DragOverlay } from './overlay';
6
+ export type { DraggableId, DroppableId, DragData, IPoint, IDraggable, IDraggableOptions, IDroppable, IDroppableOptions, IDropInfo, IDragEndResult, IDnDProviderProps, } from './types';
7
+ export type { ISortableOptions, ISortableItem, ISortablePayload } from './sortable';
package/dist/index.mjs ADDED
@@ -0,0 +1,252 @@
1
+ import { createComponent as P, Portal as R, insert as W, effect as _, className as q, setStyleProperty as C, memo as B, template as H } from "solid-js/web";
2
+ import { createEffect as M, onCleanup as y, createContext as K, createSignal as h, createMemo as E, useContext as N, Show as T } from "solid-js";
3
+ const U = (e, r, u = {}) => {
4
+ const c = u.edge ?? 50, a = u.maxSpeed ?? 15, o = (i, l) => {
5
+ if (i < c) {
6
+ const t = (c - i) / c;
7
+ return -Math.ceil(t * a);
8
+ }
9
+ if (i > l - c) {
10
+ const t = (i - (l - c)) / c;
11
+ return Math.ceil(t * a);
12
+ }
13
+ return 0;
14
+ };
15
+ M(() => {
16
+ if (!r()) return;
17
+ let i = null;
18
+ const l = () => {
19
+ const t = e();
20
+ if (t) {
21
+ const g = o(t.x, window.innerWidth), f = o(t.y, window.innerHeight);
22
+ (g !== 0 || f !== 0) && window.scrollBy(g, f);
23
+ }
24
+ i = requestAnimationFrame(l);
25
+ };
26
+ i = requestAnimationFrame(l), y(() => {
27
+ i !== null && cancelAnimationFrame(i);
28
+ });
29
+ });
30
+ }, O = K(), S = () => {
31
+ const e = N(O);
32
+ if (!e)
33
+ throw new Error("[@capsuletech/dnd] useDnD must be used inside <DnDProvider>");
34
+ return e;
35
+ }, Z = (e) => {
36
+ const [r, u] = h(null), [c, a] = h(null), [o, i] = h(null), [l, t] = h(null), g = /* @__PURE__ */ new Map(), f = /* @__PURE__ */ new Map(), D = /* @__PURE__ */ new WeakMap();
37
+ let p = null, w = null;
38
+ const b = (n, s) => {
39
+ let d = document.elementFromPoint(n, s);
40
+ for (; d; ) {
41
+ const v = D.get(d);
42
+ if (v) return f.get(v) ?? null;
43
+ d = d.parentElement;
44
+ }
45
+ return null;
46
+ }, Y = E(() => {
47
+ const n = c(), s = l();
48
+ if (!n || !s) return !1;
49
+ const d = f.get(s);
50
+ return d ? d.accepts(n) : !1;
51
+ }), L = (n) => {
52
+ i({
53
+ x: n.clientX,
54
+ y: n.clientY
55
+ });
56
+ const s = b(n.clientX, n.clientY);
57
+ t(s?.id ?? null);
58
+ }, x = (n) => {
59
+ const s = c(), d = r();
60
+ if (!s || !d) {
61
+ I();
62
+ return;
63
+ }
64
+ const v = b(n.clientX, n.clientY);
65
+ if (v?.accepts(s)) {
66
+ const m = v.el.getBoundingClientRect(), k = {
67
+ draggableId: d,
68
+ droppableId: v.id,
69
+ pointer: {
70
+ x: n.clientX,
71
+ y: n.clientY
72
+ },
73
+ ratio: {
74
+ x: m.width ? (n.clientX - m.left) / m.width : 0,
75
+ y: m.height ? (n.clientY - m.top) / m.height : 0
76
+ }
77
+ };
78
+ v.onDrop?.(s, k), e.onDragEnd?.({
79
+ kind: "drop",
80
+ data: s,
81
+ info: k
82
+ });
83
+ } else
84
+ e.onDragEnd?.({
85
+ kind: "cancel",
86
+ data: s,
87
+ draggableId: d
88
+ });
89
+ I();
90
+ }, A = (n) => {
91
+ if (n.key !== "Escape") return;
92
+ const s = c(), d = r();
93
+ s && d && e.onDragEnd?.({
94
+ kind: "cancel",
95
+ data: s,
96
+ draggableId: d
97
+ }), I();
98
+ }, I = () => {
99
+ if (p && w !== null)
100
+ try {
101
+ p.releasePointerCapture(w);
102
+ } catch {
103
+ }
104
+ p = null, w = null, window.removeEventListener("pointermove", L), window.removeEventListener("pointerup", x), window.removeEventListener("pointercancel", x), window.removeEventListener("keydown", A), u(null), a(null), i(null), t(null);
105
+ }, F = (n, s) => {
106
+ const d = g.get(n);
107
+ if (!d) return;
108
+ p = d.el, w = s.pointerId;
109
+ try {
110
+ d.el.setPointerCapture(s.pointerId);
111
+ } catch {
112
+ }
113
+ const v = d.data();
114
+ u(n), a(v), i({
115
+ x: s.clientX,
116
+ y: s.clientY
117
+ }), e.onDragStart?.(v, n), window.addEventListener("pointermove", L), window.addEventListener("pointerup", x), window.addEventListener("pointercancel", x), window.addEventListener("keydown", A);
118
+ };
119
+ e.autoScroll && U(o, () => r() !== null);
120
+ const $ = {
121
+ state: {
122
+ activeId: r,
123
+ activeData: c,
124
+ pointer: o,
125
+ overId: l,
126
+ canDrop: Y
127
+ },
128
+ registerDraggable: (n) => (g.set(n.id, n), () => {
129
+ g.delete(n.id);
130
+ }),
131
+ registerDroppable: (n) => (f.set(n.id, n), D.set(n.el, n.id), () => {
132
+ f.delete(n.id), D.delete(n.el);
133
+ }),
134
+ startDrag: F
135
+ };
136
+ return P(O.Provider, {
137
+ value: $,
138
+ get children() {
139
+ return e.children;
140
+ }
141
+ });
142
+ }, j = (e) => typeof e == "function" ? e : () => e, z = (e) => {
143
+ const r = S(), u = j(e.data), c = e.disabled ?? (() => !1);
144
+ let a = null;
145
+ const o = E(() => r.state.activeId() === e.id), i = (t) => {
146
+ c() || t.button === 0 && (t.preventDefault(), r.startDrag(e.id, t));
147
+ }, l = (t) => {
148
+ if (a && a.removeEventListener("pointerdown", i), a = t, !t) return;
149
+ t.style.touchAction = "none", t.addEventListener("pointerdown", i);
150
+ const g = r.registerDraggable({
151
+ id: e.id,
152
+ data: u,
153
+ el: t
154
+ });
155
+ y(() => {
156
+ t.removeEventListener("pointerdown", i), g();
157
+ });
158
+ };
159
+ return M(() => {
160
+ if (!o()) return;
161
+ const t = document.body.style.userSelect;
162
+ document.body.style.userSelect = "none", y(() => {
163
+ document.body.style.userSelect = t;
164
+ });
165
+ }), { ref: l, isDragging: o };
166
+ }, G = (e) => {
167
+ const r = S(), u = e.accepts ?? (() => !0), c = e.disabled ?? (() => !1);
168
+ let a = null;
169
+ const o = (t) => {
170
+ a?.(), a = null, t && (a = r.registerDroppable({
171
+ id: e.id,
172
+ el: t,
173
+ accepts: u,
174
+ onDrop: e.onDrop,
175
+ data: e.data
176
+ }), y(() => {
177
+ a?.(), a = null;
178
+ }));
179
+ }, i = E(
180
+ () => !c() && r.state.overId() === e.id
181
+ ), l = E(() => {
182
+ if (!i()) return !1;
183
+ const t = r.state.activeData();
184
+ return t ? u(t) : !1;
185
+ });
186
+ return { ref: o, isOver: i, canDrop: l };
187
+ }, X = (e, r) => e.__sortable === r && typeof e.itemId == "string", ee = (e) => ({
188
+ createItem: (r) => {
189
+ const u = z({
190
+ id: `sortable:${e.id}:${r}`,
191
+ data: () => ({
192
+ __sortable: e.id,
193
+ itemId: r,
194
+ ...e.extra?.(r) ?? {}
195
+ })
196
+ }), c = G({
197
+ id: `sortable:${e.id}:${r}`,
198
+ accepts: (o) => X(o, e.id) && o.itemId !== r,
199
+ onDrop: (o, i) => {
200
+ const l = e.items(), t = l.indexOf(o.itemId), g = l.indexOf(r);
201
+ if (t === -1 || g === -1) return;
202
+ const f = l.filter((b) => b !== o.itemId), D = f.indexOf(r), p = i.ratio.y < 0.5 ? D : D + 1, w = [
203
+ ...f.slice(0, p),
204
+ o.itemId,
205
+ ...f.slice(p)
206
+ ];
207
+ e.onReorder(w);
208
+ }
209
+ });
210
+ return {
211
+ ref: (o) => {
212
+ u.ref(o), c.ref(o);
213
+ },
214
+ isDragging: u.isDragging,
215
+ isOver: c.canDrop
216
+ };
217
+ }
218
+ }), te = (e, r) => !!e && X(e, r);
219
+ var J = /* @__PURE__ */ H('<div style="position:fixed;pointer-events:none;z-index:9999;transform:translate(-50%, -50%)">');
220
+ const ne = (e) => {
221
+ const r = S(), u = () => e.offset?.x ?? 0, c = () => e.offset?.y ?? 0;
222
+ return P(T, {
223
+ get when() {
224
+ return B(() => !!r.state.activeData())() && r.state.pointer();
225
+ },
226
+ get children() {
227
+ return P(R, {
228
+ get children() {
229
+ var a = J();
230
+ return W(a, () => e.children(r.state.activeData())), _((o) => {
231
+ var i = e.class, l = `${r.state.pointer()?.x + u()}px`, t = `${r.state.pointer()?.y + c()}px`;
232
+ return i !== o.e && q(a, o.e = i), l !== o.t && C(a, "left", o.t = l), t !== o.a && C(a, "top", o.a = t), o;
233
+ }, {
234
+ e: void 0,
235
+ t: void 0,
236
+ a: void 0
237
+ }), a;
238
+ }
239
+ });
240
+ }
241
+ });
242
+ };
243
+ export {
244
+ Z as DnDProvider,
245
+ ne as DragOverlay,
246
+ z as createDraggable,
247
+ G as createDroppable,
248
+ ee as createSortable,
249
+ te as isFromSortable,
250
+ S as useDnD
251
+ };
252
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","sources":["../src/autoScroll.ts","../src/context.tsx","../src/draggable.ts","../src/droppable.ts","../src/sortable.ts","../src/overlay.tsx"],"sourcesContent":["import { type Accessor, createEffect, onCleanup } from 'solid-js';\nimport type { IPoint } from './types';\n\ninterface IAutoScrollConfig {\n /** Зона у края (px), внутри которой начинается скролл. */\n edge?: number;\n /** Максимальная скорость скролла (px/frame). */\n maxSpeed?: number;\n}\n\n/**\n * Скроллит окно когда указатель приближается к краю viewport'а.\n * Активируется через `<DnDProvider autoScroll>` — вне drag'а ничего не делает.\n */\nexport const createWindowAutoScroll = (\n pointer: Accessor<IPoint | null>,\n isActive: Accessor<boolean>,\n config: IAutoScrollConfig = {},\n) => {\n const edge = config.edge ?? 50;\n const maxSpeed = config.maxSpeed ?? 15;\n\n const delta = (coord: number, size: number) => {\n if (coord < edge) {\n const ratio = (edge - coord) / edge;\n return -Math.ceil(ratio * maxSpeed);\n }\n if (coord > size - edge) {\n const ratio = (coord - (size - edge)) / edge;\n return Math.ceil(ratio * maxSpeed);\n }\n return 0;\n };\n\n createEffect(() => {\n if (!isActive()) return;\n let rafId: number | null = null;\n\n const tick = () => {\n const p = pointer();\n if (p) {\n const dx = delta(p.x, window.innerWidth);\n const dy = delta(p.y, window.innerHeight);\n if (dx !== 0 || dy !== 0) window.scrollBy(dx, dy);\n }\n rafId = requestAnimationFrame(tick);\n };\n rafId = requestAnimationFrame(tick);\n\n onCleanup(() => {\n if (rafId !== null) cancelAnimationFrame(rafId);\n });\n });\n};\n","import {\n type Accessor,\n type Component,\n createContext,\n createMemo,\n createSignal,\n useContext,\n} from 'solid-js';\nimport { createWindowAutoScroll } from './autoScroll';\nimport type {\n DragData,\n DraggableId,\n DroppableId,\n IDnDProviderProps,\n IDraggableEntry,\n IDropInfo,\n IDroppableEntry,\n IPoint,\n} from './types';\n\ninterface IDnDContext {\n state: {\n activeId: Accessor<DraggableId | null>;\n activeData: Accessor<DragData | null>;\n pointer: Accessor<IPoint | null>;\n overId: Accessor<DroppableId | null>;\n canDrop: Accessor<boolean>;\n };\n registerDraggable: (entry: IDraggableEntry) => () => void;\n registerDroppable: (entry: IDroppableEntry) => () => void;\n startDrag: (id: DraggableId, e: PointerEvent) => void;\n}\n\nconst Ctx = createContext<IDnDContext>();\n\nexport const useDnD = (): IDnDContext => {\n const ctx = useContext(Ctx);\n if (!ctx) {\n throw new Error('[@capsuletech/dnd] useDnD must be used inside <DnDProvider>');\n }\n return ctx;\n};\n\nexport const DnDProvider: Component<IDnDProviderProps> = (props) => {\n const [activeId, setActiveId] = createSignal<DraggableId | null>(null);\n const [activeData, setActiveData] = createSignal<DragData | null>(null);\n const [pointer, setPointer] = createSignal<IPoint | null>(null);\n const [overId, setOverId] = createSignal<DroppableId | null>(null);\n\n // Не реактивные реестры — изменения не должны триггерить ре-рендер всех\n // потребителей контекста. Реактивность отдельных полей даёт signals выше.\n const draggables = new Map<DraggableId, IDraggableEntry>();\n const droppables = new Map<DroppableId, IDroppableEntry>();\n const elToDroppableId = new WeakMap<HTMLElement, DroppableId>();\n\n let captureEl: HTMLElement | null = null;\n let capturePointerId: number | null = null;\n\n const findDroppableAt = (x: number, y: number): IDroppableEntry | null => {\n let el = document.elementFromPoint(x, y) as HTMLElement | null;\n while (el) {\n const id = elToDroppableId.get(el);\n if (id) return droppables.get(id) ?? null;\n el = el.parentElement;\n }\n return null;\n };\n\n const canDrop = createMemo(() => {\n const data = activeData();\n const oid = overId();\n if (!data || !oid) return false;\n const drop = droppables.get(oid);\n if (!drop) return false;\n return drop.accepts(data);\n });\n\n const onPointerMove = (e: PointerEvent) => {\n setPointer({ x: e.clientX, y: e.clientY });\n const drop = findDroppableAt(e.clientX, e.clientY);\n setOverId(drop?.id ?? null);\n };\n\n const onPointerUp = (e: PointerEvent) => {\n const data = activeData();\n const id = activeId();\n if (!data || !id) {\n cleanup();\n return;\n }\n const drop = findDroppableAt(e.clientX, e.clientY);\n if (drop?.accepts(data)) {\n const rect = drop.el.getBoundingClientRect();\n const info: IDropInfo = {\n draggableId: id,\n droppableId: drop.id,\n pointer: { x: e.clientX, y: e.clientY },\n ratio: {\n x: rect.width ? (e.clientX - rect.left) / rect.width : 0,\n y: rect.height ? (e.clientY - rect.top) / rect.height : 0,\n },\n };\n drop.onDrop?.(data, info);\n props.onDragEnd?.({ kind: 'drop', data, info });\n } else {\n props.onDragEnd?.({ kind: 'cancel', data, draggableId: id });\n }\n cleanup();\n };\n\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key !== 'Escape') return;\n const data = activeData();\n const id = activeId();\n if (data && id) {\n props.onDragEnd?.({ kind: 'cancel', data, draggableId: id });\n }\n cleanup();\n };\n\n const cleanup = () => {\n if (captureEl && capturePointerId !== null) {\n try {\n captureEl.releasePointerCapture(capturePointerId);\n } catch {\n // pointer мог уже быть отпущен браузером — ничего страшного\n }\n }\n captureEl = null;\n capturePointerId = null;\n window.removeEventListener('pointermove', onPointerMove);\n window.removeEventListener('pointerup', onPointerUp);\n window.removeEventListener('pointercancel', onPointerUp);\n window.removeEventListener('keydown', onKeyDown);\n setActiveId(null);\n setActiveData(null);\n setPointer(null);\n setOverId(null);\n };\n\n const startDrag = (id: DraggableId, e: PointerEvent) => {\n const entry = draggables.get(id);\n if (!entry) return;\n captureEl = entry.el;\n capturePointerId = e.pointerId;\n try {\n entry.el.setPointerCapture(e.pointerId);\n } catch {\n // setPointerCapture может бросать в некоторых средах (e.g. iframe без user gesture).\n // Не критично — pointermove/up через window всё равно ловим.\n }\n const data = entry.data();\n setActiveId(id);\n setActiveData(data);\n setPointer({ x: e.clientX, y: e.clientY });\n props.onDragStart?.(data, id);\n\n window.addEventListener('pointermove', onPointerMove);\n window.addEventListener('pointerup', onPointerUp);\n window.addEventListener('pointercancel', onPointerUp);\n window.addEventListener('keydown', onKeyDown);\n };\n\n // Auto-scroll по краям viewport. Активен только при активном drag'е, если\n // включено через `<DnDProvider autoScroll>`.\n if (props.autoScroll) {\n createWindowAutoScroll(pointer, () => activeId() !== null);\n }\n\n const api: IDnDContext = {\n state: { activeId, activeData, pointer, overId, canDrop },\n registerDraggable: (entry) => {\n draggables.set(entry.id, entry);\n return () => {\n draggables.delete(entry.id);\n };\n },\n registerDroppable: (entry) => {\n droppables.set(entry.id, entry);\n elToDroppableId.set(entry.el, entry.id);\n return () => {\n droppables.delete(entry.id);\n elToDroppableId.delete(entry.el);\n };\n },\n startDrag,\n };\n\n return <Ctx.Provider value={api}>{props.children}</Ctx.Provider>;\n};\n","import { type Accessor, createEffect, createMemo, onCleanup } from 'solid-js';\nimport { useDnD } from './context';\nimport type { DragData, IDraggable, IDraggableOptions } from './types';\n\nconst toAccessor = <T>(v: Accessor<T> | T): Accessor<T> =>\n typeof v === 'function' ? (v as Accessor<T>) : () => v;\n\n/**\n * Primitive для draggable-источника. Применяется как `ref={drag.ref}` на\n * элементе, который должен быть перетаскиваемым.\n *\n * Старт drag'а — `pointerdown` (любая кнопка/палец). На draggable-элементе\n * проставляется `touch-action: none`, чтобы touch-drag не конфликтовал со\n * скроллом страницы.\n *\n * Текстовое выделение во время drag'а гасится глобально через `user-select:\n * none` пока `isDragging`.\n */\nexport const createDraggable = <T extends DragData = DragData>(\n options: IDraggableOptions<T>,\n): IDraggable => {\n const dnd = useDnD();\n const data = toAccessor(options.data);\n const disabled = options.disabled ?? (() => false);\n\n let elRef: HTMLElement | null = null;\n\n const isDragging = createMemo(() => dnd.state.activeId() === options.id);\n\n const onPointerDown = (e: PointerEvent) => {\n if (disabled()) return;\n // Только основная кнопка (или touch — у touch button === 0)\n if (e.button !== 0) return;\n e.preventDefault();\n dnd.startDrag(options.id, e);\n };\n\n const ref = (el: HTMLElement) => {\n if (elRef) {\n elRef.removeEventListener('pointerdown', onPointerDown);\n }\n elRef = el;\n if (!el) return;\n el.style.touchAction = 'none';\n el.addEventListener('pointerdown', onPointerDown);\n\n const unregister = dnd.registerDraggable({\n id: options.id,\n data: data as Accessor<DragData>,\n el,\n });\n\n onCleanup(() => {\n el.removeEventListener('pointerdown', onPointerDown);\n unregister();\n });\n };\n\n // Глобальное гашение text-selection пока тянем — иначе drag по тексту\n // выделяет случайные участки.\n createEffect(() => {\n if (!isDragging()) return;\n const prev = document.body.style.userSelect;\n document.body.style.userSelect = 'none';\n onCleanup(() => {\n document.body.style.userSelect = prev;\n });\n });\n\n return { ref, isDragging };\n};\n","import { type Accessor, createMemo, onCleanup } from 'solid-js';\nimport { useDnD } from './context';\nimport type { DragData, IDroppable, IDroppableOptions } from './types';\n\n/**\n * Primitive для drop-цели. `ref={drop.ref}` на элементе.\n *\n * `accepts(data)` вызывается на каждом move/up для решения «можно ли сюда».\n * Возвращаемые сигналы:\n * - `isOver` — pointer находится над этим droppable;\n * - `canDrop` — `isOver && accepts(activeData)`.\n *\n * Если `disabled()` true, droppable считается незарегистрированным.\n */\nexport const createDroppable = <T extends DragData = DragData>(\n options: IDroppableOptions<T>,\n): IDroppable => {\n const dnd = useDnD();\n const accepts = options.accepts ?? (() => true);\n const disabled = options.disabled ?? (() => false);\n\n let cleanupRegister: (() => void) | null = null;\n\n const ref = (el: HTMLElement) => {\n cleanupRegister?.();\n cleanupRegister = null;\n if (!el) return;\n cleanupRegister = dnd.registerDroppable({\n id: options.id,\n el,\n accepts: accepts as (d: DragData) => boolean,\n onDrop: options.onDrop as IDroppableOptions<DragData>['onDrop'],\n data: options.data as DragData | undefined,\n });\n onCleanup(() => {\n cleanupRegister?.();\n cleanupRegister = null;\n });\n };\n\n const isOver: Accessor<boolean> = createMemo(\n () => !disabled() && dnd.state.overId() === options.id,\n );\n\n const canDrop: Accessor<boolean> = createMemo(() => {\n if (!isOver()) return false;\n const data = dnd.state.activeData() as T | null;\n if (!data) return false;\n return accepts(data);\n });\n\n return { ref, isOver, canDrop };\n};\n","import type { Accessor } from 'solid-js';\nimport { createDraggable } from './draggable';\nimport { createDroppable } from './droppable';\nimport type { DragData, IDropInfo } from './types';\n\nexport interface ISortableOptions<TExtra extends DragData = DragData> {\n /** Уникальный id экземпляра sortable (если их несколько). */\n id: string;\n /** Текущий порядок item-id'ов. */\n items: Accessor<string[]>;\n /** Колбэк с новым порядком после успешного reorder'а. */\n onReorder: (newOrder: string[]) => void;\n /**\n * Доп. поля в drag-data — пробрасываются вместе с системными `sortableId`,\n * `itemId`. Полезно для cross-sortable accepts-логики (например, у каждого\n * item'а есть `type`, чтобы внешний droppable знал, можно ли его принять).\n */\n extra?: (itemId: string) => TExtra;\n}\n\nexport interface ISortableItem {\n ref: (el: HTMLElement) => void;\n isDragging: Accessor<boolean>;\n isOver: Accessor<boolean>;\n}\n\ninterface ISortablePayload {\n /** Маркер sortable-источника. */\n __sortable: string;\n itemId: string;\n [k: string]: unknown;\n}\n\nconst isSortablePayload = (d: DragData, sortableId: string): d is ISortablePayload =>\n (d as any).__sortable === sortableId && typeof (d as any).itemId === 'string';\n\n/**\n * Лёгкая обёртка над `createDraggable + createDroppable` для упорядоченных\n * списков (например, дочерние ноды в редакторе-дереве).\n *\n * Для каждого item получается **одновременно** и draggable, и droppable.\n * Drop в верхнюю половину — вставка до target'а, в нижнюю — после.\n *\n * Sortable принимает only-свои item'ы (через `__sortable === id`). Drop из\n * палитры/иного источника нужно ловить отдельным `createDroppable` на\n * контейнере.\n */\nexport const createSortable = <TExtra extends DragData = DragData>(\n options: ISortableOptions<TExtra>,\n) => {\n return {\n createItem: (itemId: string): ISortableItem => {\n const drag = createDraggable<ISortablePayload>({\n id: `sortable:${options.id}:${itemId}`,\n data: () => ({\n __sortable: options.id,\n itemId,\n ...(options.extra?.(itemId) ?? ({} as TExtra)),\n }),\n });\n\n const drop = createDroppable<ISortablePayload>({\n id: `sortable:${options.id}:${itemId}`,\n accepts: (data) => isSortablePayload(data, options.id) && data.itemId !== itemId,\n onDrop: (data, info: IDropInfo) => {\n const current = options.items();\n const fromIdx = current.indexOf(data.itemId);\n const toIdx = current.indexOf(itemId);\n if (fromIdx === -1 || toIdx === -1) return;\n\n // Удаляем dragged из текущего порядка\n const withoutDragged = current.filter((id) => id !== data.itemId);\n // Целевой индекс после удаления — пересчитываем\n const baseIdx = withoutDragged.indexOf(itemId);\n // Вставка до/после в зависимости от вертикальной позиции pointer'а\n const insertAt = info.ratio.y < 0.5 ? baseIdx : baseIdx + 1;\n\n const next = [\n ...withoutDragged.slice(0, insertAt),\n data.itemId,\n ...withoutDragged.slice(insertAt),\n ];\n options.onReorder(next);\n },\n });\n\n const ref = (el: HTMLElement) => {\n drag.ref(el);\n drop.ref(el);\n };\n\n return {\n ref,\n isDragging: drag.isDragging,\n isOver: drop.canDrop,\n };\n },\n };\n};\n\n// Хелпер для intercept-логики: подписаться на текущий activeData и проверить,\n// что это item from-sortable. Удобно для зон-приёмников из других контекстов.\nexport const isFromSortable = (\n data: DragData | null,\n sortableId: string,\n): data is ISortablePayload => !!data && isSortablePayload(data as DragData, sortableId);\n\n/** Re-export для совместного использования с другими droppable'ами. */\nexport type { ISortablePayload };\n","import { type JSX, Show } from 'solid-js';\nimport { Portal } from 'solid-js/web';\nimport { useDnD } from './context';\nimport type { DragData } from './types';\n\ninterface IDragOverlayProps {\n /** Render-prop с текущим payload'ом перетаскиваемого элемента. */\n children: (data: DragData) => JSX.Element;\n /** Смещение preview относительно курсора (по умолчанию centered под pointer'ом). */\n offset?: { x: number; y: number };\n /**\n * Дополнительный класс для preview-обёртки. Сама обёртка — `position: fixed`\n * с `pointer-events: none`, чтобы курсор «прошивал» её и попадал в droppable.\n */\n class?: string;\n}\n\n/**\n * Призрак перетаскиваемого элемента, рендерится в `<body>` через Portal.\n * Не реагирует на pointer-events — иначе hit-testing не нашёл бы droppable\n * под курсором.\n */\nexport const DragOverlay = (props: IDragOverlayProps) => {\n const dnd = useDnD();\n const offX = () => props.offset?.x ?? 0;\n const offY = () => props.offset?.y ?? 0;\n\n return (\n <Show when={dnd.state.activeData() && dnd.state.pointer()}>\n <Portal>\n <div\n class={props.class}\n style={{\n position: 'fixed',\n left: `${dnd.state.pointer()?.x + offX()}px`,\n top: `${dnd.state.pointer()?.y + offY()}px`,\n 'pointer-events': 'none',\n 'z-index': '9999',\n transform: 'translate(-50%, -50%)',\n }}\n >\n {props.children(dnd.state.activeData()!)}\n </div>\n </Portal>\n </Show>\n );\n};\n"],"names":["createWindowAutoScroll","pointer","isActive","config","edge","maxSpeed","delta","coord","size","ratio","createEffect","rafId","tick","p","dx","dy","onCleanup","Ctx","createContext","useDnD","ctx","useContext","Error","DnDProvider","props","activeId","setActiveId","createSignal","activeData","setActiveData","setPointer","overId","setOverId","draggables","Map","droppables","elToDroppableId","WeakMap","captureEl","capturePointerId","findDroppableAt","x","y","el","document","elementFromPoint","id","get","parentElement","canDrop","createMemo","data","oid","drop","accepts","onPointerMove","e","clientX","clientY","onPointerUp","cleanup","rect","getBoundingClientRect","info","draggableId","droppableId","width","left","height","top","onDrop","onDragEnd","kind","onKeyDown","key","releasePointerCapture","window","removeEventListener","startDrag","entry","pointerId","setPointerCapture","onDragStart","addEventListener","autoScroll","api","state","registerDraggable","set","delete","registerDroppable","_$createComponent","Provider","value","children","toAccessor","v","createDraggable","options","dnd","disabled","elRef","isDragging","onPointerDown","ref","unregister","prev","createDroppable","cleanupRegister","isOver","isSortablePayload","d","sortableId","createSortable","itemId","drag","current","fromIdx","toIdx","withoutDragged","baseIdx","insertAt","next","isFromSortable","DragOverlay","offX","offset","offY","Show","when","_$memo","Portal","_el$","_tmpl$","_$insert","_$effect","_p$","_v$","class","_v$2","_v$3","_$className","t","_$setStyleProperty","a","undefined"],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ import { JSX } from 'solid-js';
2
+ import { DragData } from './types';
3
+ interface IDragOverlayProps {
4
+ /** Render-prop с текущим payload'ом перетаскиваемого элемента. */
5
+ children: (data: DragData) => JSX.Element;
6
+ /** Смещение preview относительно курсора (по умолчанию centered под pointer'ом). */
7
+ offset?: {
8
+ x: number;
9
+ y: number;
10
+ };
11
+ /**
12
+ * Дополнительный класс для preview-обёртки. Сама обёртка — `position: fixed`
13
+ * с `pointer-events: none`, чтобы курсор «прошивал» её и попадал в droppable.
14
+ */
15
+ class?: string;
16
+ }
17
+ /**
18
+ * Призрак перетаскиваемого элемента, рендерится в `<body>` через Portal.
19
+ * Не реагирует на pointer-events — иначе hit-testing не нашёл бы droppable
20
+ * под курсором.
21
+ */
22
+ export declare const DragOverlay: (props: IDragOverlayProps) => JSX.Element;
23
+ export {};
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@capsuletech/web-dnd",
3
+ "version": "0.0.17",
4
+ "type": "module",
5
+ "main": "./index.mjs",
6
+ "types": "./index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "import": "./index.mjs",
11
+ "default": "./index.mjs"
12
+ }
13
+ },
14
+ "dependencies": {},
15
+ "peerDependencies": {
16
+ "solid-js": "^1.9.0"
17
+ }
18
+ }
@@ -0,0 +1,44 @@
1
+ import { Accessor } from 'solid-js';
2
+ import { DragData } from './types';
3
+ export interface ISortableOptions<TExtra extends DragData = DragData> {
4
+ /** Уникальный id экземпляра sortable (если их несколько). */
5
+ id: string;
6
+ /** Текущий порядок item-id'ов. */
7
+ items: Accessor<string[]>;
8
+ /** Колбэк с новым порядком после успешного reorder'а. */
9
+ onReorder: (newOrder: string[]) => void;
10
+ /**
11
+ * Доп. поля в drag-data — пробрасываются вместе с системными `sortableId`,
12
+ * `itemId`. Полезно для cross-sortable accepts-логики (например, у каждого
13
+ * item'а есть `type`, чтобы внешний droppable знал, можно ли его принять).
14
+ */
15
+ extra?: (itemId: string) => TExtra;
16
+ }
17
+ export interface ISortableItem {
18
+ ref: (el: HTMLElement) => void;
19
+ isDragging: Accessor<boolean>;
20
+ isOver: Accessor<boolean>;
21
+ }
22
+ interface ISortablePayload {
23
+ /** Маркер sortable-источника. */
24
+ __sortable: string;
25
+ itemId: string;
26
+ [k: string]: unknown;
27
+ }
28
+ /**
29
+ * Лёгкая обёртка над `createDraggable + createDroppable` для упорядоченных
30
+ * списков (например, дочерние ноды в редакторе-дереве).
31
+ *
32
+ * Для каждого item получается **одновременно** и draggable, и droppable.
33
+ * Drop в верхнюю половину — вставка до target'а, в нижнюю — после.
34
+ *
35
+ * Sortable принимает only-свои item'ы (через `__sortable === id`). Drop из
36
+ * палитры/иного источника нужно ловить отдельным `createDroppable` на
37
+ * контейнере.
38
+ */
39
+ export declare const createSortable: <TExtra extends DragData = DragData>(options: ISortableOptions<TExtra>) => {
40
+ createItem: (itemId: string) => ISortableItem;
41
+ };
42
+ export declare const isFromSortable: (data: DragData | null, sortableId: string) => data is ISortablePayload;
43
+ /** Re-export для совместного использования с другими droppable'ами. */
44
+ export type { ISortablePayload };
@@ -0,0 +1,80 @@
1
+ import { Accessor, JSX } from 'solid-js';
2
+ export type DraggableId = string;
3
+ export type DroppableId = string;
4
+ /**
5
+ * Полезная нагрузка перетаскиваемого элемента. Произвольная — библиотека к
6
+ * содержимому не привязана. Потребитель сам сужает тип в `accepts` / `onDrop`.
7
+ */
8
+ export type DragData = Record<string, unknown>;
9
+ export interface IPoint {
10
+ x: number;
11
+ y: number;
12
+ }
13
+ export interface IDraggableEntry<T extends DragData = DragData> {
14
+ id: DraggableId;
15
+ /** Реактивный геттер — payload может меняться (e.g. имя ноды в дереве). */
16
+ data: Accessor<T>;
17
+ el: HTMLElement;
18
+ }
19
+ export interface IDroppableEntry<T extends DragData = DragData> {
20
+ id: DroppableId;
21
+ el: HTMLElement;
22
+ /** Предикат, разрешающий или запрещающий drop конкретного draggable'а. */
23
+ accepts: (data: T) => boolean;
24
+ onDrop?: (data: T, info: IDropInfo) => void;
25
+ /** Доп. данные о droppable'е (e.g. parent-id ноды в дереве) — пробрасываются в onDrop. */
26
+ data?: T;
27
+ }
28
+ export interface IDropInfo {
29
+ /** Источник drag'а. */
30
+ draggableId: DraggableId;
31
+ /** Цель drop'а. */
32
+ droppableId: DroppableId;
33
+ /** Координаты pointer'а в момент drop'а (viewport). */
34
+ pointer: IPoint;
35
+ /** Координаты pointer'а относительно droppable-элемента (0..1 нормализовано). */
36
+ ratio: IPoint;
37
+ }
38
+ export interface IDraggableOptions<T extends DragData = DragData> {
39
+ id: DraggableId;
40
+ /** Реактивная функция → payload. Вызывается при каждом start drag. */
41
+ data: Accessor<T> | T;
42
+ /** Отключить draggable (e.g. disabled state). */
43
+ disabled?: Accessor<boolean>;
44
+ }
45
+ export interface IDraggable {
46
+ ref: (el: HTMLElement) => void;
47
+ isDragging: Accessor<boolean>;
48
+ }
49
+ export interface IDroppableOptions<T extends DragData = DragData> {
50
+ id: DroppableId;
51
+ /** По умолчанию принимает всё. */
52
+ accepts?: (data: T) => boolean;
53
+ onDrop?: (data: T, info: IDropInfo) => void;
54
+ /** Опциональные мета-данные droppable'а. */
55
+ data?: T;
56
+ disabled?: Accessor<boolean>;
57
+ }
58
+ export interface IDroppable {
59
+ ref: (el: HTMLElement) => void;
60
+ /** Курсор сейчас над этим droppable. */
61
+ isOver: Accessor<boolean>;
62
+ /** isOver && accepts(activeData) — удобный shorthand. */
63
+ canDrop: Accessor<boolean>;
64
+ }
65
+ export interface IDnDProviderProps {
66
+ children: JSX.Element;
67
+ /** Скроллить window когда pointer у края viewport'а. По умолчанию off. */
68
+ autoScroll?: boolean;
69
+ onDragStart?: (data: DragData, draggableId: DraggableId) => void;
70
+ onDragEnd?: (result: IDragEndResult) => void;
71
+ }
72
+ export type IDragEndResult = {
73
+ kind: 'drop';
74
+ data: DragData;
75
+ info: IDropInfo;
76
+ } | {
77
+ kind: 'cancel';
78
+ data: DragData;
79
+ draggableId: DraggableId;
80
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@capsuletech/web-dnd",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.mjs",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.mjs",
11
+ "default": "./dist/index.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "!**/*.tsbuildinfo"
18
+ ],
19
+ "dependencies": {},
20
+ "peerDependencies": {
21
+ "solid-js": "^1.9.0"
22
+ },
23
+ "devDependencies": {
24
+ "@capsuletech/shared-vite": "0.1.0"
25
+ },
26
+ "scripts": {
27
+ "build": "vite build"
28
+ }
29
+ }
@@ -0,0 +1,54 @@
1
+ import { type Accessor, createEffect, onCleanup } from 'solid-js';
2
+ import type { IPoint } from './types';
3
+
4
+ interface IAutoScrollConfig {
5
+ /** Зона у края (px), внутри которой начинается скролл. */
6
+ edge?: number;
7
+ /** Максимальная скорость скролла (px/frame). */
8
+ maxSpeed?: number;
9
+ }
10
+
11
+ /**
12
+ * Скроллит окно когда указатель приближается к краю viewport'а.
13
+ * Активируется через `<DnDProvider autoScroll>` — вне drag'а ничего не делает.
14
+ */
15
+ export const createWindowAutoScroll = (
16
+ pointer: Accessor<IPoint | null>,
17
+ isActive: Accessor<boolean>,
18
+ config: IAutoScrollConfig = {},
19
+ ) => {
20
+ const edge = config.edge ?? 50;
21
+ const maxSpeed = config.maxSpeed ?? 15;
22
+
23
+ const delta = (coord: number, size: number) => {
24
+ if (coord < edge) {
25
+ const ratio = (edge - coord) / edge;
26
+ return -Math.ceil(ratio * maxSpeed);
27
+ }
28
+ if (coord > size - edge) {
29
+ const ratio = (coord - (size - edge)) / edge;
30
+ return Math.ceil(ratio * maxSpeed);
31
+ }
32
+ return 0;
33
+ };
34
+
35
+ createEffect(() => {
36
+ if (!isActive()) return;
37
+ let rafId: number | null = null;
38
+
39
+ const tick = () => {
40
+ const p = pointer();
41
+ if (p) {
42
+ const dx = delta(p.x, window.innerWidth);
43
+ const dy = delta(p.y, window.innerHeight);
44
+ if (dx !== 0 || dy !== 0) window.scrollBy(dx, dy);
45
+ }
46
+ rafId = requestAnimationFrame(tick);
47
+ };
48
+ rafId = requestAnimationFrame(tick);
49
+
50
+ onCleanup(() => {
51
+ if (rafId !== null) cancelAnimationFrame(rafId);
52
+ });
53
+ });
54
+ };
@@ -0,0 +1,190 @@
1
+ import {
2
+ type Accessor,
3
+ type Component,
4
+ createContext,
5
+ createMemo,
6
+ createSignal,
7
+ useContext,
8
+ } from 'solid-js';
9
+ import { createWindowAutoScroll } from './autoScroll';
10
+ import type {
11
+ DragData,
12
+ DraggableId,
13
+ DroppableId,
14
+ IDnDProviderProps,
15
+ IDraggableEntry,
16
+ IDropInfo,
17
+ IDroppableEntry,
18
+ IPoint,
19
+ } from './types';
20
+
21
+ interface IDnDContext {
22
+ state: {
23
+ activeId: Accessor<DraggableId | null>;
24
+ activeData: Accessor<DragData | null>;
25
+ pointer: Accessor<IPoint | null>;
26
+ overId: Accessor<DroppableId | null>;
27
+ canDrop: Accessor<boolean>;
28
+ };
29
+ registerDraggable: (entry: IDraggableEntry) => () => void;
30
+ registerDroppable: (entry: IDroppableEntry) => () => void;
31
+ startDrag: (id: DraggableId, e: PointerEvent) => void;
32
+ }
33
+
34
+ const Ctx = createContext<IDnDContext>();
35
+
36
+ export const useDnD = (): IDnDContext => {
37
+ const ctx = useContext(Ctx);
38
+ if (!ctx) {
39
+ throw new Error('[@capsuletech/dnd] useDnD must be used inside <DnDProvider>');
40
+ }
41
+ return ctx;
42
+ };
43
+
44
+ export const DnDProvider: Component<IDnDProviderProps> = (props) => {
45
+ const [activeId, setActiveId] = createSignal<DraggableId | null>(null);
46
+ const [activeData, setActiveData] = createSignal<DragData | null>(null);
47
+ const [pointer, setPointer] = createSignal<IPoint | null>(null);
48
+ const [overId, setOverId] = createSignal<DroppableId | null>(null);
49
+
50
+ // Не реактивные реестры — изменения не должны триггерить ре-рендер всех
51
+ // потребителей контекста. Реактивность отдельных полей даёт signals выше.
52
+ const draggables = new Map<DraggableId, IDraggableEntry>();
53
+ const droppables = new Map<DroppableId, IDroppableEntry>();
54
+ const elToDroppableId = new WeakMap<HTMLElement, DroppableId>();
55
+
56
+ let captureEl: HTMLElement | null = null;
57
+ let capturePointerId: number | null = null;
58
+
59
+ const findDroppableAt = (x: number, y: number): IDroppableEntry | null => {
60
+ let el = document.elementFromPoint(x, y) as HTMLElement | null;
61
+ while (el) {
62
+ const id = elToDroppableId.get(el);
63
+ if (id) return droppables.get(id) ?? null;
64
+ el = el.parentElement;
65
+ }
66
+ return null;
67
+ };
68
+
69
+ const canDrop = createMemo(() => {
70
+ const data = activeData();
71
+ const oid = overId();
72
+ if (!data || !oid) return false;
73
+ const drop = droppables.get(oid);
74
+ if (!drop) return false;
75
+ return drop.accepts(data);
76
+ });
77
+
78
+ const onPointerMove = (e: PointerEvent) => {
79
+ setPointer({ x: e.clientX, y: e.clientY });
80
+ const drop = findDroppableAt(e.clientX, e.clientY);
81
+ setOverId(drop?.id ?? null);
82
+ };
83
+
84
+ const onPointerUp = (e: PointerEvent) => {
85
+ const data = activeData();
86
+ const id = activeId();
87
+ if (!data || !id) {
88
+ cleanup();
89
+ return;
90
+ }
91
+ const drop = findDroppableAt(e.clientX, e.clientY);
92
+ if (drop?.accepts(data)) {
93
+ const rect = drop.el.getBoundingClientRect();
94
+ const info: IDropInfo = {
95
+ draggableId: id,
96
+ droppableId: drop.id,
97
+ pointer: { x: e.clientX, y: e.clientY },
98
+ ratio: {
99
+ x: rect.width ? (e.clientX - rect.left) / rect.width : 0,
100
+ y: rect.height ? (e.clientY - rect.top) / rect.height : 0,
101
+ },
102
+ };
103
+ drop.onDrop?.(data, info);
104
+ props.onDragEnd?.({ kind: 'drop', data, info });
105
+ } else {
106
+ props.onDragEnd?.({ kind: 'cancel', data, draggableId: id });
107
+ }
108
+ cleanup();
109
+ };
110
+
111
+ const onKeyDown = (e: KeyboardEvent) => {
112
+ if (e.key !== 'Escape') return;
113
+ const data = activeData();
114
+ const id = activeId();
115
+ if (data && id) {
116
+ props.onDragEnd?.({ kind: 'cancel', data, draggableId: id });
117
+ }
118
+ cleanup();
119
+ };
120
+
121
+ const cleanup = () => {
122
+ if (captureEl && capturePointerId !== null) {
123
+ try {
124
+ captureEl.releasePointerCapture(capturePointerId);
125
+ } catch {
126
+ // pointer мог уже быть отпущен браузером — ничего страшного
127
+ }
128
+ }
129
+ captureEl = null;
130
+ capturePointerId = null;
131
+ window.removeEventListener('pointermove', onPointerMove);
132
+ window.removeEventListener('pointerup', onPointerUp);
133
+ window.removeEventListener('pointercancel', onPointerUp);
134
+ window.removeEventListener('keydown', onKeyDown);
135
+ setActiveId(null);
136
+ setActiveData(null);
137
+ setPointer(null);
138
+ setOverId(null);
139
+ };
140
+
141
+ const startDrag = (id: DraggableId, e: PointerEvent) => {
142
+ const entry = draggables.get(id);
143
+ if (!entry) return;
144
+ captureEl = entry.el;
145
+ capturePointerId = e.pointerId;
146
+ try {
147
+ entry.el.setPointerCapture(e.pointerId);
148
+ } catch {
149
+ // setPointerCapture может бросать в некоторых средах (e.g. iframe без user gesture).
150
+ // Не критично — pointermove/up через window всё равно ловим.
151
+ }
152
+ const data = entry.data();
153
+ setActiveId(id);
154
+ setActiveData(data);
155
+ setPointer({ x: e.clientX, y: e.clientY });
156
+ props.onDragStart?.(data, id);
157
+
158
+ window.addEventListener('pointermove', onPointerMove);
159
+ window.addEventListener('pointerup', onPointerUp);
160
+ window.addEventListener('pointercancel', onPointerUp);
161
+ window.addEventListener('keydown', onKeyDown);
162
+ };
163
+
164
+ // Auto-scroll по краям viewport. Активен только при активном drag'е, если
165
+ // включено через `<DnDProvider autoScroll>`.
166
+ if (props.autoScroll) {
167
+ createWindowAutoScroll(pointer, () => activeId() !== null);
168
+ }
169
+
170
+ const api: IDnDContext = {
171
+ state: { activeId, activeData, pointer, overId, canDrop },
172
+ registerDraggable: (entry) => {
173
+ draggables.set(entry.id, entry);
174
+ return () => {
175
+ draggables.delete(entry.id);
176
+ };
177
+ },
178
+ registerDroppable: (entry) => {
179
+ droppables.set(entry.id, entry);
180
+ elToDroppableId.set(entry.el, entry.id);
181
+ return () => {
182
+ droppables.delete(entry.id);
183
+ elToDroppableId.delete(entry.el);
184
+ };
185
+ },
186
+ startDrag,
187
+ };
188
+
189
+ return <Ctx.Provider value={api}>{props.children}</Ctx.Provider>;
190
+ };
@@ -0,0 +1,71 @@
1
+ import { type Accessor, createEffect, createMemo, onCleanup } from 'solid-js';
2
+ import { useDnD } from './context';
3
+ import type { DragData, IDraggable, IDraggableOptions } from './types';
4
+
5
+ const toAccessor = <T>(v: Accessor<T> | T): Accessor<T> =>
6
+ typeof v === 'function' ? (v as Accessor<T>) : () => v;
7
+
8
+ /**
9
+ * Primitive для draggable-источника. Применяется как `ref={drag.ref}` на
10
+ * элементе, который должен быть перетаскиваемым.
11
+ *
12
+ * Старт drag'а — `pointerdown` (любая кнопка/палец). На draggable-элементе
13
+ * проставляется `touch-action: none`, чтобы touch-drag не конфликтовал со
14
+ * скроллом страницы.
15
+ *
16
+ * Текстовое выделение во время drag'а гасится глобально через `user-select:
17
+ * none` пока `isDragging`.
18
+ */
19
+ export const createDraggable = <T extends DragData = DragData>(
20
+ options: IDraggableOptions<T>,
21
+ ): IDraggable => {
22
+ const dnd = useDnD();
23
+ const data = toAccessor(options.data);
24
+ const disabled = options.disabled ?? (() => false);
25
+
26
+ let elRef: HTMLElement | null = null;
27
+
28
+ const isDragging = createMemo(() => dnd.state.activeId() === options.id);
29
+
30
+ const onPointerDown = (e: PointerEvent) => {
31
+ if (disabled()) return;
32
+ // Только основная кнопка (или touch — у touch button === 0)
33
+ if (e.button !== 0) return;
34
+ e.preventDefault();
35
+ dnd.startDrag(options.id, e);
36
+ };
37
+
38
+ const ref = (el: HTMLElement) => {
39
+ if (elRef) {
40
+ elRef.removeEventListener('pointerdown', onPointerDown);
41
+ }
42
+ elRef = el;
43
+ if (!el) return;
44
+ el.style.touchAction = 'none';
45
+ el.addEventListener('pointerdown', onPointerDown);
46
+
47
+ const unregister = dnd.registerDraggable({
48
+ id: options.id,
49
+ data: data as Accessor<DragData>,
50
+ el,
51
+ });
52
+
53
+ onCleanup(() => {
54
+ el.removeEventListener('pointerdown', onPointerDown);
55
+ unregister();
56
+ });
57
+ };
58
+
59
+ // Глобальное гашение text-selection пока тянем — иначе drag по тексту
60
+ // выделяет случайные участки.
61
+ createEffect(() => {
62
+ if (!isDragging()) return;
63
+ const prev = document.body.style.userSelect;
64
+ document.body.style.userSelect = 'none';
65
+ onCleanup(() => {
66
+ document.body.style.userSelect = prev;
67
+ });
68
+ });
69
+
70
+ return { ref, isDragging };
71
+ };
@@ -0,0 +1,53 @@
1
+ import { type Accessor, createMemo, onCleanup } from 'solid-js';
2
+ import { useDnD } from './context';
3
+ import type { DragData, IDroppable, IDroppableOptions } from './types';
4
+
5
+ /**
6
+ * Primitive для drop-цели. `ref={drop.ref}` на элементе.
7
+ *
8
+ * `accepts(data)` вызывается на каждом move/up для решения «можно ли сюда».
9
+ * Возвращаемые сигналы:
10
+ * - `isOver` — pointer находится над этим droppable;
11
+ * - `canDrop` — `isOver && accepts(activeData)`.
12
+ *
13
+ * Если `disabled()` true, droppable считается незарегистрированным.
14
+ */
15
+ export const createDroppable = <T extends DragData = DragData>(
16
+ options: IDroppableOptions<T>,
17
+ ): IDroppable => {
18
+ const dnd = useDnD();
19
+ const accepts = options.accepts ?? (() => true);
20
+ const disabled = options.disabled ?? (() => false);
21
+
22
+ let cleanupRegister: (() => void) | null = null;
23
+
24
+ const ref = (el: HTMLElement) => {
25
+ cleanupRegister?.();
26
+ cleanupRegister = null;
27
+ if (!el) return;
28
+ cleanupRegister = dnd.registerDroppable({
29
+ id: options.id,
30
+ el,
31
+ accepts: accepts as (d: DragData) => boolean,
32
+ onDrop: options.onDrop as IDroppableOptions<DragData>['onDrop'],
33
+ data: options.data as DragData | undefined,
34
+ });
35
+ onCleanup(() => {
36
+ cleanupRegister?.();
37
+ cleanupRegister = null;
38
+ });
39
+ };
40
+
41
+ const isOver: Accessor<boolean> = createMemo(
42
+ () => !disabled() && dnd.state.overId() === options.id,
43
+ );
44
+
45
+ const canDrop: Accessor<boolean> = createMemo(() => {
46
+ if (!isOver()) return false;
47
+ const data = dnd.state.activeData() as T | null;
48
+ if (!data) return false;
49
+ return accepts(data);
50
+ });
51
+
52
+ return { ref, isOver, canDrop };
53
+ };
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { DnDProvider, useDnD } from './context';
2
+ export { createDraggable } from './draggable';
3
+ export { createDroppable } from './droppable';
4
+ export { createSortable, isFromSortable } from './sortable';
5
+ export { DragOverlay } from './overlay';
6
+ export type {
7
+ DraggableId,
8
+ DroppableId,
9
+ DragData,
10
+ IPoint,
11
+ IDraggable,
12
+ IDraggableOptions,
13
+ IDroppable,
14
+ IDroppableOptions,
15
+ IDropInfo,
16
+ IDragEndResult,
17
+ IDnDProviderProps,
18
+ } from './types';
19
+ export type { ISortableOptions, ISortableItem, ISortablePayload } from './sortable';
@@ -0,0 +1,47 @@
1
+ import { type JSX, Show } from 'solid-js';
2
+ import { Portal } from 'solid-js/web';
3
+ import { useDnD } from './context';
4
+ import type { DragData } from './types';
5
+
6
+ interface IDragOverlayProps {
7
+ /** Render-prop с текущим payload'ом перетаскиваемого элемента. */
8
+ children: (data: DragData) => JSX.Element;
9
+ /** Смещение preview относительно курсора (по умолчанию centered под pointer'ом). */
10
+ offset?: { x: number; y: number };
11
+ /**
12
+ * Дополнительный класс для preview-обёртки. Сама обёртка — `position: fixed`
13
+ * с `pointer-events: none`, чтобы курсор «прошивал» её и попадал в droppable.
14
+ */
15
+ class?: string;
16
+ }
17
+
18
+ /**
19
+ * Призрак перетаскиваемого элемента, рендерится в `<body>` через Portal.
20
+ * Не реагирует на pointer-events — иначе hit-testing не нашёл бы droppable
21
+ * под курсором.
22
+ */
23
+ export const DragOverlay = (props: IDragOverlayProps) => {
24
+ const dnd = useDnD();
25
+ const offX = () => props.offset?.x ?? 0;
26
+ const offY = () => props.offset?.y ?? 0;
27
+
28
+ return (
29
+ <Show when={dnd.state.activeData() && dnd.state.pointer()}>
30
+ <Portal>
31
+ <div
32
+ class={props.class}
33
+ style={{
34
+ position: 'fixed',
35
+ left: `${dnd.state.pointer()?.x + offX()}px`,
36
+ top: `${dnd.state.pointer()?.y + offY()}px`,
37
+ 'pointer-events': 'none',
38
+ 'z-index': '9999',
39
+ transform: 'translate(-50%, -50%)',
40
+ }}
41
+ >
42
+ {props.children(dnd.state.activeData()!)}
43
+ </div>
44
+ </Portal>
45
+ </Show>
46
+ );
47
+ };
@@ -0,0 +1,109 @@
1
+ import type { Accessor } from 'solid-js';
2
+ import { createDraggable } from './draggable';
3
+ import { createDroppable } from './droppable';
4
+ import type { DragData, IDropInfo } from './types';
5
+
6
+ export interface ISortableOptions<TExtra extends DragData = DragData> {
7
+ /** Уникальный id экземпляра sortable (если их несколько). */
8
+ id: string;
9
+ /** Текущий порядок item-id'ов. */
10
+ items: Accessor<string[]>;
11
+ /** Колбэк с новым порядком после успешного reorder'а. */
12
+ onReorder: (newOrder: string[]) => void;
13
+ /**
14
+ * Доп. поля в drag-data — пробрасываются вместе с системными `sortableId`,
15
+ * `itemId`. Полезно для cross-sortable accepts-логики (например, у каждого
16
+ * item'а есть `type`, чтобы внешний droppable знал, можно ли его принять).
17
+ */
18
+ extra?: (itemId: string) => TExtra;
19
+ }
20
+
21
+ export interface ISortableItem {
22
+ ref: (el: HTMLElement) => void;
23
+ isDragging: Accessor<boolean>;
24
+ isOver: Accessor<boolean>;
25
+ }
26
+
27
+ interface ISortablePayload {
28
+ /** Маркер sortable-источника. */
29
+ __sortable: string;
30
+ itemId: string;
31
+ [k: string]: unknown;
32
+ }
33
+
34
+ const isSortablePayload = (d: DragData, sortableId: string): d is ISortablePayload =>
35
+ (d as any).__sortable === sortableId && typeof (d as any).itemId === 'string';
36
+
37
+ /**
38
+ * Лёгкая обёртка над `createDraggable + createDroppable` для упорядоченных
39
+ * списков (например, дочерние ноды в редакторе-дереве).
40
+ *
41
+ * Для каждого item получается **одновременно** и draggable, и droppable.
42
+ * Drop в верхнюю половину — вставка до target'а, в нижнюю — после.
43
+ *
44
+ * Sortable принимает only-свои item'ы (через `__sortable === id`). Drop из
45
+ * палитры/иного источника нужно ловить отдельным `createDroppable` на
46
+ * контейнере.
47
+ */
48
+ export const createSortable = <TExtra extends DragData = DragData>(
49
+ options: ISortableOptions<TExtra>,
50
+ ) => {
51
+ return {
52
+ createItem: (itemId: string): ISortableItem => {
53
+ const drag = createDraggable<ISortablePayload>({
54
+ id: `sortable:${options.id}:${itemId}`,
55
+ data: () => ({
56
+ __sortable: options.id,
57
+ itemId,
58
+ ...(options.extra?.(itemId) ?? ({} as TExtra)),
59
+ }),
60
+ });
61
+
62
+ const drop = createDroppable<ISortablePayload>({
63
+ id: `sortable:${options.id}:${itemId}`,
64
+ accepts: (data) => isSortablePayload(data, options.id) && data.itemId !== itemId,
65
+ onDrop: (data, info: IDropInfo) => {
66
+ const current = options.items();
67
+ const fromIdx = current.indexOf(data.itemId);
68
+ const toIdx = current.indexOf(itemId);
69
+ if (fromIdx === -1 || toIdx === -1) return;
70
+
71
+ // Удаляем dragged из текущего порядка
72
+ const withoutDragged = current.filter((id) => id !== data.itemId);
73
+ // Целевой индекс после удаления — пересчитываем
74
+ const baseIdx = withoutDragged.indexOf(itemId);
75
+ // Вставка до/после в зависимости от вертикальной позиции pointer'а
76
+ const insertAt = info.ratio.y < 0.5 ? baseIdx : baseIdx + 1;
77
+
78
+ const next = [
79
+ ...withoutDragged.slice(0, insertAt),
80
+ data.itemId,
81
+ ...withoutDragged.slice(insertAt),
82
+ ];
83
+ options.onReorder(next);
84
+ },
85
+ });
86
+
87
+ const ref = (el: HTMLElement) => {
88
+ drag.ref(el);
89
+ drop.ref(el);
90
+ };
91
+
92
+ return {
93
+ ref,
94
+ isDragging: drag.isDragging,
95
+ isOver: drop.canDrop,
96
+ };
97
+ },
98
+ };
99
+ };
100
+
101
+ // Хелпер для intercept-логики: подписаться на текущий activeData и проверить,
102
+ // что это item from-sortable. Удобно для зон-приёмников из других контекстов.
103
+ export const isFromSortable = (
104
+ data: DragData | null,
105
+ sortableId: string,
106
+ ): data is ISortablePayload => !!data && isSortablePayload(data as DragData, sortableId);
107
+
108
+ /** Re-export для совместного использования с другими droppable'ами. */
109
+ export type { ISortablePayload };
package/src/types.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { Accessor, JSX } from 'solid-js';
2
+
3
+ export type DraggableId = string;
4
+ export type DroppableId = string;
5
+
6
+ /**
7
+ * Полезная нагрузка перетаскиваемого элемента. Произвольная — библиотека к
8
+ * содержимому не привязана. Потребитель сам сужает тип в `accepts` / `onDrop`.
9
+ */
10
+ export type DragData = Record<string, unknown>;
11
+
12
+ export interface IPoint {
13
+ x: number;
14
+ y: number;
15
+ }
16
+
17
+ export interface IDraggableEntry<T extends DragData = DragData> {
18
+ id: DraggableId;
19
+ /** Реактивный геттер — payload может меняться (e.g. имя ноды в дереве). */
20
+ data: Accessor<T>;
21
+ el: HTMLElement;
22
+ }
23
+
24
+ export interface IDroppableEntry<T extends DragData = DragData> {
25
+ id: DroppableId;
26
+ el: HTMLElement;
27
+ /** Предикат, разрешающий или запрещающий drop конкретного draggable'а. */
28
+ accepts: (data: T) => boolean;
29
+ onDrop?: (data: T, info: IDropInfo) => void;
30
+ /** Доп. данные о droppable'е (e.g. parent-id ноды в дереве) — пробрасываются в onDrop. */
31
+ data?: T;
32
+ }
33
+
34
+ export interface IDropInfo {
35
+ /** Источник drag'а. */
36
+ draggableId: DraggableId;
37
+ /** Цель drop'а. */
38
+ droppableId: DroppableId;
39
+ /** Координаты pointer'а в момент drop'а (viewport). */
40
+ pointer: IPoint;
41
+ /** Координаты pointer'а относительно droppable-элемента (0..1 нормализовано). */
42
+ ratio: IPoint;
43
+ }
44
+
45
+ export interface IDraggableOptions<T extends DragData = DragData> {
46
+ id: DraggableId;
47
+ /** Реактивная функция → payload. Вызывается при каждом start drag. */
48
+ data: Accessor<T> | T;
49
+ /** Отключить draggable (e.g. disabled state). */
50
+ disabled?: Accessor<boolean>;
51
+ }
52
+
53
+ export interface IDraggable {
54
+ ref: (el: HTMLElement) => void;
55
+ isDragging: Accessor<boolean>;
56
+ }
57
+
58
+ export interface IDroppableOptions<T extends DragData = DragData> {
59
+ id: DroppableId;
60
+ /** По умолчанию принимает всё. */
61
+ accepts?: (data: T) => boolean;
62
+ onDrop?: (data: T, info: IDropInfo) => void;
63
+ /** Опциональные мета-данные droppable'а. */
64
+ data?: T;
65
+ disabled?: Accessor<boolean>;
66
+ }
67
+
68
+ export interface IDroppable {
69
+ ref: (el: HTMLElement) => void;
70
+ /** Курсор сейчас над этим droppable. */
71
+ isOver: Accessor<boolean>;
72
+ /** isOver && accepts(activeData) — удобный shorthand. */
73
+ canDrop: Accessor<boolean>;
74
+ }
75
+
76
+ export interface IDnDProviderProps {
77
+ children: JSX.Element;
78
+ /** Скроллить window когда pointer у края viewport'а. По умолчанию off. */
79
+ autoScroll?: boolean;
80
+ onDragStart?: (data: DragData, draggableId: DraggableId) => void;
81
+ onDragEnd?: (result: IDragEndResult) => void;
82
+ }
83
+
84
+ export type IDragEndResult =
85
+ | { kind: 'drop'; data: DragData; info: IDropInfo }
86
+ | { kind: 'cancel'; data: DragData; draggableId: DraggableId };