@suphark/ui 0.37.0 → 0.39.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/CHANGELOG.md +35 -1
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/components/shared/ui-text.tsx +4 -0
- package/src/components/sortable/sortable-list.tsx +663 -52
- package/src/lib/sortable-order.ts +177 -15
- package/src/sortable.ts +7 -0
|
@@ -5,11 +5,12 @@ import {
|
|
|
5
5
|
closestCenter,
|
|
6
6
|
DndContext,
|
|
7
7
|
type DragEndEvent,
|
|
8
|
+
type DragMoveEvent,
|
|
8
9
|
type DragOverEvent,
|
|
9
10
|
DragOverlay,
|
|
10
11
|
type DragStartEvent,
|
|
11
12
|
type DropAnimation,
|
|
12
|
-
|
|
13
|
+
getClientRect,
|
|
13
14
|
getFirstCollision,
|
|
14
15
|
KeyboardSensor,
|
|
15
16
|
MeasuringStrategy,
|
|
@@ -33,10 +34,14 @@ import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
|
33
34
|
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
34
35
|
import { errorMessage } from "@suphark/ui/lib/action-result";
|
|
35
36
|
import {
|
|
37
|
+
flatItems,
|
|
38
|
+
gatherItems,
|
|
36
39
|
groupOfItem,
|
|
37
40
|
moveItemToGroup,
|
|
38
|
-
|
|
41
|
+
moveItemToRoot,
|
|
42
|
+
moveRootEntry,
|
|
39
43
|
reorderItems,
|
|
44
|
+
rootEntries,
|
|
40
45
|
type SortableOrder,
|
|
41
46
|
sameOrder,
|
|
42
47
|
UNGROUPED,
|
|
@@ -46,6 +51,8 @@ import { GripVerticalIcon } from "lucide-react";
|
|
|
46
51
|
import {
|
|
47
52
|
type CSSProperties,
|
|
48
53
|
createContext,
|
|
54
|
+
type KeyboardEvent,
|
|
55
|
+
type MouseEvent,
|
|
49
56
|
type ReactNode,
|
|
50
57
|
useCallback,
|
|
51
58
|
useContext,
|
|
@@ -54,6 +61,7 @@ import {
|
|
|
54
61
|
useMemo,
|
|
55
62
|
useRef,
|
|
56
63
|
useState,
|
|
64
|
+
useSyncExternalStore,
|
|
57
65
|
} from "react";
|
|
58
66
|
import { createPortal } from "react-dom";
|
|
59
67
|
import { toast } from "sonner";
|
|
@@ -72,9 +80,53 @@ const SORT_TRANSITION = { duration: 200, easing: EASING };
|
|
|
72
80
|
const DROP_ANIMATION: DropAnimation = {
|
|
73
81
|
duration: 200,
|
|
74
82
|
easing: EASING,
|
|
75
|
-
|
|
83
|
+
// ช่องจาง ๆ ของแถวต้นทางเราใส่ผ่าน style ของ React เองแล้ว — side effect ของ dnd-kit จะจำค่า 0.4 นั้น
|
|
84
|
+
// ไว้เป็น "ค่าเดิม" แล้วเขียนคืนหลังปล่อย ทำให้แถวจางค้าง (เจอกับหัวกลุ่ม)
|
|
85
|
+
sideEffects: null,
|
|
76
86
|
};
|
|
77
87
|
|
|
88
|
+
/** สิ่งที่กำลังลาก — `ids` = ทุกรายการที่ลากไปด้วยกัน (ลากแบบ bulk; ลากตัวเดียว = `[id]`) */
|
|
89
|
+
export interface SortableDrag {
|
|
90
|
+
kind: SortableKind;
|
|
91
|
+
id: string;
|
|
92
|
+
ids: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type SelectMode = "toggle" | "range" | "all" | "clear";
|
|
96
|
+
|
|
97
|
+
/** ระยะเลื่อนของหัวกลุ่มแต่ละกลุ่มระหว่างลากกลุ่ม — แถวรายการในกลุ่มอ่านไปเลื่อนตาม (ทั้งก้อนขยับพร้อมกัน) */
|
|
98
|
+
interface GroupShift {
|
|
99
|
+
transform: string | undefined;
|
|
100
|
+
transition: string | undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createShiftStore() {
|
|
104
|
+
const shifts = new Map<string, GroupShift>();
|
|
105
|
+
const listeners = new Set<() => void>();
|
|
106
|
+
return {
|
|
107
|
+
get: (id: string) => shifts.get(id),
|
|
108
|
+
set(id: string, shift: GroupShift | undefined) {
|
|
109
|
+
const prev = shifts.get(id);
|
|
110
|
+
if (prev?.transform === shift?.transform && prev?.transition === shift?.transition) return;
|
|
111
|
+
if (shift) shifts.set(id, shift);
|
|
112
|
+
else shifts.delete(id);
|
|
113
|
+
for (const listener of listeners) listener();
|
|
114
|
+
},
|
|
115
|
+
clear() {
|
|
116
|
+
if (shifts.size === 0) return;
|
|
117
|
+
shifts.clear();
|
|
118
|
+
for (const listener of listeners) listener();
|
|
119
|
+
},
|
|
120
|
+
subscribe(listener: () => void) {
|
|
121
|
+
listeners.add(listener);
|
|
122
|
+
return () => {
|
|
123
|
+
listeners.delete(listener);
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
type ShiftStore = ReturnType<typeof createShiftStore>;
|
|
129
|
+
|
|
78
130
|
interface SortableState {
|
|
79
131
|
/** กำลังลากชนิดไหนอยู่ (null = ไม่ได้ลาก) — ใช้ปิดที่วางของอีกชนิด */
|
|
80
132
|
dragging: SortableKind | null;
|
|
@@ -82,22 +134,57 @@ interface SortableState {
|
|
|
82
134
|
handleLabel: string;
|
|
83
135
|
/** มี `renderOverlay` = แถวต้นทางเป็นที่ว่างจาง ๆ ส่วนของที่ลอยตามเมาส์คือ overlay */
|
|
84
136
|
overlay: boolean;
|
|
137
|
+
/** กลุ่มที่กำลังถูกลาก (แถวรายการของกลุ่มนี้จางตามหัวกลุ่ม) */
|
|
138
|
+
activeGroup: string | null;
|
|
139
|
+
/** กำลังลากของในชั้นบนสุด (กลุ่ม หรือรายการไม่มีกลุ่มเมื่อ `freeLayout`) — ระยะเลื่อนมาจาก provider */
|
|
140
|
+
rootDrag: boolean;
|
|
141
|
+
freeLayout: boolean;
|
|
142
|
+
/** รายการอื่นที่ลากไปพร้อมกันแบบ bulk (ไม่รวมตัวที่จับ) — แสดงจาง ๆ ระหว่างลาก */
|
|
143
|
+
carried: ReadonlySet<string>;
|
|
144
|
+
multiSelect: boolean;
|
|
145
|
+
selected: ReadonlySet<string>;
|
|
146
|
+
select: (id: string, mode: SelectMode) => void;
|
|
147
|
+
groupOf: (itemId: string) => string | undefined;
|
|
148
|
+
register: (kind: SortableKind, id: string, node: HTMLElement | null) => void;
|
|
149
|
+
shifts: ShiftStore;
|
|
85
150
|
}
|
|
86
151
|
|
|
152
|
+
const EMPTY = new Set<string>();
|
|
87
153
|
const SortableStateContext = createContext<SortableState>({
|
|
88
154
|
dragging: null,
|
|
89
155
|
disabled: true,
|
|
90
156
|
handleLabel: "ลากเพื่อจัดลำดับ",
|
|
91
157
|
overlay: false,
|
|
158
|
+
activeGroup: null,
|
|
159
|
+
rootDrag: false,
|
|
160
|
+
freeLayout: false,
|
|
161
|
+
carried: EMPTY,
|
|
162
|
+
multiSelect: false,
|
|
163
|
+
selected: EMPTY,
|
|
164
|
+
select: () => {},
|
|
165
|
+
groupOf: () => undefined,
|
|
166
|
+
register: () => {},
|
|
167
|
+
shifts: createShiftStore(),
|
|
92
168
|
});
|
|
93
169
|
|
|
94
170
|
/**
|
|
95
171
|
* สถานะการลากตอนนี้ — ใช้ตัดสินใจเรนเดอร์ที่วางเอง เช่นแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม"
|
|
96
172
|
* ที่ควรโผล่เฉพาะตอน `dragging === "item"` (กลุ่มที่ไม่มีหัวกลุ่มต้องมีที่วางของตัวเอง)
|
|
173
|
+
* `selected`/`clearSelection` ใช้ทำแถบ "เลือก N รายการ" เมื่อเปิด `multiSelect`
|
|
97
174
|
*/
|
|
98
|
-
export function useSortableState(): {
|
|
175
|
+
export function useSortableState(): {
|
|
176
|
+
dragging: SortableKind | null;
|
|
177
|
+
disabled: boolean;
|
|
178
|
+
selected: string[];
|
|
179
|
+
clearSelection: () => void;
|
|
180
|
+
} {
|
|
99
181
|
const state = useContext(SortableStateContext);
|
|
100
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
dragging: state.dragging,
|
|
184
|
+
disabled: state.disabled,
|
|
185
|
+
selected: [...state.selected],
|
|
186
|
+
clearSelection: () => state.select("", "clear"),
|
|
187
|
+
};
|
|
101
188
|
}
|
|
102
189
|
|
|
103
190
|
/** ข้อมูลของสิ่งที่กำลังลาก ส่งให้แอปประกอบข้อความเอง (แอปรู้ชื่อคีย์/ชื่อกลุ่ม แพ็กเกจไม่รู้) */
|
|
@@ -109,6 +196,8 @@ export interface SortableAnnouncementContext {
|
|
|
109
196
|
/** ตำแหน่งในกลุ่ม/ในลำดับกลุ่ม เริ่มที่ 1 */
|
|
110
197
|
index: number;
|
|
111
198
|
total: number;
|
|
199
|
+
/** จำนวนรายการที่ลากไปด้วยกัน (ลากแบบ bulk > 1) */
|
|
200
|
+
count: number;
|
|
112
201
|
}
|
|
113
202
|
|
|
114
203
|
export interface SortableAnnouncements {
|
|
@@ -155,7 +244,24 @@ export interface SortableProviderProps {
|
|
|
155
244
|
* ส่งมา = แถวต้นทางกลายเป็นช่องว่างจาง ๆ บอกตำแหน่งที่จะวาง และมีแอนิเมชันตอนปล่อย
|
|
156
245
|
* วาดใน portal ที่ `body` จึงคืน `<div>` ได้แม้รายการจริงเป็นแถว `<tr>` ของตาราง
|
|
157
246
|
*/
|
|
158
|
-
renderOverlay?: (drag:
|
|
247
|
+
renderOverlay?: (drag: SortableDrag) => ReactNode;
|
|
248
|
+
/**
|
|
249
|
+
* เลือกหลายรายการแล้วลากไปพร้อมกัน: ⌘/Ctrl+คลิก = เลือกทีละตัว, Shift+คลิก = เลือกเป็นช่วง,
|
|
250
|
+
* คลิกเฉย ๆ = ล้าง · ที่จับ (คีย์บอร์ด): `x` = เลือก/ไม่เลือก, ⌘/Ctrl+A = ทั้งหมด, Esc = ล้าง
|
|
251
|
+
* แถวต้องกระจาย `selectProps` จาก `useSortableRow` (มี `data-selected` ไว้ทำสีแถวที่เลือก)
|
|
252
|
+
* ลากตัวที่เลือกอยู่ = ลากทุกตัวที่เลือกไปรวมที่จุดวาง เรียงตามลำดับเดิม
|
|
253
|
+
*/
|
|
254
|
+
multiSelect?: boolean;
|
|
255
|
+
/**
|
|
256
|
+
* ชั้นบนสุดจัดได้อิสระ: กลุ่มกับรายการที่ไม่มีกลุ่มสลับที่กันได้ทุกแบบ (กลุ่มขึ้นไปเหนือรายการ /
|
|
257
|
+
* รายการไปอยู่ระหว่างกลุ่ม / เอารายการออกจากกลุ่มไปวางก่อนกลุ่มไหนก็ได้) — แอปต้องเรนเดอร์ตาม
|
|
258
|
+
* `rootEntries(order)` และบันทึก `sortableOrderToPayload(order).root` · ชี้ครึ่งบนของหัวกลุ่ม = วางก่อนกลุ่ม,
|
|
259
|
+
* ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก · ไม่เปิด = รายการไม่มีกลุ่มอยู่บนสุดเสมอ (แบบเดิม)
|
|
260
|
+
*/
|
|
261
|
+
freeLayout?: boolean;
|
|
262
|
+
/** รายการที่เลือก (ควบคุมจากภายนอก) — ไม่ส่ง = provider เก็บเอง */
|
|
263
|
+
selected?: string[];
|
|
264
|
+
onSelectedChange?: (ids: string[]) => void;
|
|
159
265
|
children: ReactNode;
|
|
160
266
|
}
|
|
161
267
|
|
|
@@ -179,13 +285,26 @@ export function SortableProvider({
|
|
|
179
285
|
announcements,
|
|
180
286
|
id,
|
|
181
287
|
renderOverlay,
|
|
288
|
+
multiSelect = false,
|
|
289
|
+
freeLayout = false,
|
|
290
|
+
selected: selectedProp,
|
|
291
|
+
onSelectedChange,
|
|
182
292
|
children,
|
|
183
293
|
}: SortableProviderProps) {
|
|
184
294
|
const fallbackId = useId();
|
|
185
295
|
const text = useUiText();
|
|
186
296
|
const mounted = useMounted();
|
|
187
297
|
const [dragging, setDragging] = useState<SortableKind | null>(null);
|
|
188
|
-
const [activeDrag, setActiveDrag] = useState<
|
|
298
|
+
const [activeDrag, setActiveDrag] = useState<SortableDrag | null>(null);
|
|
299
|
+
const draggingRef = useRef<SortableKind | null>(null);
|
|
300
|
+
// รายการที่ลากไปด้วยกันแบบ bulk (รวมตัวที่จับ เรียงตามลำดับที่แสดง) — null = ลากตัวเดียว
|
|
301
|
+
const bulk = useRef<string[] | null>(null);
|
|
302
|
+
const nodes = useRef({
|
|
303
|
+
groups: new Map<string, HTMLElement>(),
|
|
304
|
+
items: new Map<string, HTMLElement>(),
|
|
305
|
+
groupOfNode: new WeakMap<HTMLElement, string>(),
|
|
306
|
+
});
|
|
307
|
+
const shifts = useMemo(createShiftStore, []);
|
|
189
308
|
// กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
|
|
190
309
|
const lastOverId = useRef<UniqueIdentifier | null>(null);
|
|
191
310
|
const movedAcross = useRef(false);
|
|
@@ -194,6 +313,229 @@ export function SortableProvider({
|
|
|
194
313
|
const latest = useRef(order);
|
|
195
314
|
latest.current = order;
|
|
196
315
|
|
|
316
|
+
// ---------- เลือกหลายรายการ ----------
|
|
317
|
+
const [ownSelected, setOwnSelected] = useState<string[]>([]);
|
|
318
|
+
const selectedList = selectedProp ?? ownSelected;
|
|
319
|
+
const selected = useMemo<ReadonlySet<string>>(() => {
|
|
320
|
+
if (!multiSelect || selectedList.length === 0) return EMPTY;
|
|
321
|
+
const present = new Set(flatItems(order));
|
|
322
|
+
return new Set(selectedList.filter((x) => present.has(x)));
|
|
323
|
+
}, [multiSelect, selectedList, order]);
|
|
324
|
+
const selectedRef = useRef(selected);
|
|
325
|
+
selectedRef.current = selected;
|
|
326
|
+
const anchor = useRef<string | null>(null);
|
|
327
|
+
const onSelectedChangeRef = useRef(onSelectedChange);
|
|
328
|
+
onSelectedChangeRef.current = onSelectedChange;
|
|
329
|
+
const controlled = selectedProp !== undefined;
|
|
330
|
+
|
|
331
|
+
const setSelected = useCallback(
|
|
332
|
+
(ids: string[]) => {
|
|
333
|
+
if (!controlled) setOwnSelected(ids);
|
|
334
|
+
onSelectedChangeRef.current?.(ids);
|
|
335
|
+
},
|
|
336
|
+
[controlled],
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const select = useCallback(
|
|
340
|
+
(itemId: string, mode: SelectMode) => {
|
|
341
|
+
const all = flatItems(latest.current);
|
|
342
|
+
// เลือกเป็นช่วง/ทั้งหมด = เฉพาะแถวที่แสดงอยู่ (ไม่หยิบของในกลุ่มที่ย่อไว้โดยไม่รู้ตัว)
|
|
343
|
+
const visible = all.filter((x) => nodes.current.items.get(x)?.isConnected);
|
|
344
|
+
const current = selectedRef.current;
|
|
345
|
+
let next: Set<string>;
|
|
346
|
+
if (mode === "clear") next = new Set();
|
|
347
|
+
else if (mode === "all") next = new Set(visible);
|
|
348
|
+
else if (mode === "toggle") {
|
|
349
|
+
next = new Set(current);
|
|
350
|
+
if (next.has(itemId)) next.delete(itemId);
|
|
351
|
+
else next.add(itemId);
|
|
352
|
+
anchor.current = itemId;
|
|
353
|
+
} else {
|
|
354
|
+
const from = visible.indexOf(anchor.current ?? itemId);
|
|
355
|
+
const to = visible.indexOf(itemId);
|
|
356
|
+
next = new Set(current);
|
|
357
|
+
if (from >= 0 && to >= 0) {
|
|
358
|
+
for (const x of visible.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(x);
|
|
359
|
+
} else next.add(itemId);
|
|
360
|
+
if (anchor.current === null) anchor.current = itemId;
|
|
361
|
+
}
|
|
362
|
+
if (mode === "clear" || mode === "all") anchor.current = null;
|
|
363
|
+
if (next.size === current.size && [...next].every((x) => current.has(x))) return;
|
|
364
|
+
setSelected(all.filter((x) => next.has(x)));
|
|
365
|
+
},
|
|
366
|
+
[setSelected],
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// Esc ที่ไหนก็ได้ = ล้างการเลือก (ระหว่างลาก Esc เป็นของการยกเลิกลาก)
|
|
370
|
+
const hasSelection = selected.size > 0;
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (!hasSelection) return;
|
|
373
|
+
function onKey(event: globalThis.KeyboardEvent) {
|
|
374
|
+
if (event.key === "Escape" && draggingRef.current === null) select("", "clear");
|
|
375
|
+
}
|
|
376
|
+
document.addEventListener("keydown", onKey);
|
|
377
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
378
|
+
}, [hasSelection, select]);
|
|
379
|
+
|
|
380
|
+
const register = useCallback((kind: SortableKind, rowId: string, node: HTMLElement | null) => {
|
|
381
|
+
const map = kind === "group" ? nodes.current.groups : nodes.current.items;
|
|
382
|
+
if (node) {
|
|
383
|
+
map.set(rowId, node);
|
|
384
|
+
if (kind === "group") nodes.current.groupOfNode.set(node, rowId);
|
|
385
|
+
} else map.delete(rowId);
|
|
386
|
+
}, []);
|
|
387
|
+
|
|
388
|
+
/** กรอบของทั้งก้อน: หัวกลุ่ม + แถวรายการที่แสดงอยู่ (ไม่สนใจ transform ระหว่างลาก) */
|
|
389
|
+
const blockRect = useCallback((groupId: string, header: HTMLElement) => {
|
|
390
|
+
let { top, bottom, left, right } = getClientRect(header, { ignoreTransform: true });
|
|
391
|
+
for (const itemId of latest.current.items[groupId] ?? []) {
|
|
392
|
+
const node = nodes.current.items.get(itemId);
|
|
393
|
+
if (!node?.isConnected) continue;
|
|
394
|
+
const r = getClientRect(node, { ignoreTransform: true });
|
|
395
|
+
top = Math.min(top, r.top);
|
|
396
|
+
bottom = Math.max(bottom, r.bottom);
|
|
397
|
+
left = Math.min(left, r.left);
|
|
398
|
+
right = Math.max(right, r.right);
|
|
399
|
+
}
|
|
400
|
+
return { top, bottom, left, right, width: right - left, height: bottom - top };
|
|
401
|
+
}, []);
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* ขนาดของที่วางตอนลากกลุ่ม = หัวกลุ่ม "รวมแถวรายการของกลุ่มนั้น" ทั้งก้อน
|
|
405
|
+
* (เดิมวัดแค่หัวกลุ่ม กลุ่มอื่นจึงเลื่อนหลบแค่ความสูงหัวกลุ่มเดียว แถวรายการไม่ขยับ)
|
|
406
|
+
*/
|
|
407
|
+
const measureDroppable = useCallback(
|
|
408
|
+
(element: HTMLElement) => {
|
|
409
|
+
const groupId =
|
|
410
|
+
draggingRef.current === "group" ? nodes.current.groupOfNode.get(element) : undefined;
|
|
411
|
+
return groupId
|
|
412
|
+
? blockRect(groupId, element)
|
|
413
|
+
: getClientRect(element, { ignoreTransform: true });
|
|
414
|
+
},
|
|
415
|
+
[blockRect],
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// ---------- เลื่อนทั้งก้อนในชั้นบนสุด (แบบรายการ: ตัวอื่นหลบ ช่องว่างของตัวที่ลากเลื่อนไปจุดวาง) ----------
|
|
419
|
+
const freeRef = useRef(freeLayout);
|
|
420
|
+
freeRef.current = freeLayout;
|
|
421
|
+
const overlayOn = Boolean(renderOverlay);
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* ลำดับที่ตัวที่ลากเรียงอยู่ด้วย (เป็น dnd id) — `freeLayout` = ชั้นบนสุดทั้งหมด,
|
|
425
|
+
* ไม่เปิด = เฉพาะกลุ่ม (รายการไม่มีกลุ่มเรียงกันเองด้วย dnd-kit) · null = ไม่ใช่การลากในชั้นบนสุด
|
|
426
|
+
*/
|
|
427
|
+
const entryKeys = useCallback((current: SortableOrder, activeKey: string): string[] | null => {
|
|
428
|
+
if (freeRef.current) {
|
|
429
|
+
const keys = rootEntries(current).map((e) => dndId(e.kind, e.id));
|
|
430
|
+
return keys.includes(activeKey) ? keys : null;
|
|
431
|
+
}
|
|
432
|
+
return kindOf(activeKey) === "g" ? current.groups.map((g) => dndId("group", g)) : null;
|
|
433
|
+
}, []);
|
|
434
|
+
|
|
435
|
+
/** กรอบของสิ่งหนึ่งในชั้นบนสุด: กลุ่ม = ทั้งก้อน, รายการ = แถวเดียว */
|
|
436
|
+
const measureKey = useCallback(
|
|
437
|
+
(key: string) => {
|
|
438
|
+
const raw = rawOf(key);
|
|
439
|
+
if (kindOf(key) === "g") {
|
|
440
|
+
const header = nodes.current.groups.get(raw);
|
|
441
|
+
return header?.isConnected ? blockRect(raw, header) : null;
|
|
442
|
+
}
|
|
443
|
+
const node = nodes.current.items.get(raw);
|
|
444
|
+
return node?.isConnected ? getClientRect(node, { ignoreTransform: true }) : null;
|
|
445
|
+
},
|
|
446
|
+
[blockRect],
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
/** จะวาง "ก่อน" อะไร (dnd id; null = ท้ายสุดของลำดับ) — null ทั้ง object = ไม่ใช่เป้าหมายในชั้นบนสุด */
|
|
450
|
+
const [target, setTargetState] = useState<{ before: string | null } | null>(null);
|
|
451
|
+
const targetRef = useRef(target);
|
|
452
|
+
const setTarget = useCallback((next: { before: string | null } | null) => {
|
|
453
|
+
const prev = targetRef.current;
|
|
454
|
+
if (prev === next || (prev && next && prev.before === next.before)) return;
|
|
455
|
+
targetRef.current = next;
|
|
456
|
+
setTargetState(next);
|
|
457
|
+
}, []);
|
|
458
|
+
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
if (!activeDrag) return;
|
|
461
|
+
const activeKey = dndId(activeDrag.kind, activeDrag.id);
|
|
462
|
+
const keys = entryKeys(order, activeKey);
|
|
463
|
+
if (!keys) {
|
|
464
|
+
// รายการที่ลากเข้าไปอยู่ในกลุ่มแล้ว → dnd-kit ของกลุ่มนั้นคุมต่อ
|
|
465
|
+
shifts.clear();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const rest = keys.filter((k) => k !== activeKey);
|
|
469
|
+
const at = target
|
|
470
|
+
? target.before === null
|
|
471
|
+
? rest.length
|
|
472
|
+
: rest.indexOf(target.before)
|
|
473
|
+
: keys.indexOf(activeKey);
|
|
474
|
+
const final = [...rest.slice(0, Math.max(0, at)), activeKey, ...rest.slice(Math.max(0, at))];
|
|
475
|
+
const rects = new Map(keys.map((k) => [k, measureKey(k)] as const));
|
|
476
|
+
const first = rects.get(keys[0] ?? "");
|
|
477
|
+
const second = rects.get(keys[1] ?? "");
|
|
478
|
+
if (!first) return;
|
|
479
|
+
// ช่องไฟระหว่างก้อน (ตาราง = 0, การ์ดมีระยะห่าง) — ใช้ระยะจริงระหว่างสองก้อนแรก
|
|
480
|
+
const gap = second ? Math.max(0, second.top - first.bottom) : 0;
|
|
481
|
+
const transition = `transform ${SORT_TRANSITION.duration}ms ${EASING}`;
|
|
482
|
+
let cursor = first.top;
|
|
483
|
+
for (const key of final) {
|
|
484
|
+
const rect = rects.get(key);
|
|
485
|
+
if (!rect) continue;
|
|
486
|
+
const dy = cursor - rect.top;
|
|
487
|
+
cursor += rect.height + gap;
|
|
488
|
+
// ไม่มี overlay = ตัวที่ลากลอยตามเมาส์เอง (กลุ่ม: onDragMove, รายการ: dnd-kit)
|
|
489
|
+
if (key === activeKey && !overlayOn) continue;
|
|
490
|
+
shifts.set(key, { transform: `translate3d(0px, ${dy}px, 0)`, transition });
|
|
491
|
+
}
|
|
492
|
+
}, [activeDrag, target, order, overlayOn, shifts, entryKeys, measureKey]);
|
|
493
|
+
|
|
494
|
+
/** ตั้งเป้าหมายในชั้นบนสุดจากสิ่งที่ชี้อยู่ + ตัวที่ลากเลยครึ่งของมันหรือยัง */
|
|
495
|
+
function updateTarget(
|
|
496
|
+
active: DragMoveEvent["active"],
|
|
497
|
+
over: DragMoveEvent["over"],
|
|
498
|
+
keyboard: boolean,
|
|
499
|
+
) {
|
|
500
|
+
const activeKey = String(active.id);
|
|
501
|
+
const keys = entryKeys(latest.current, activeKey);
|
|
502
|
+
if (!keys) {
|
|
503
|
+
setTarget(null);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (!over) return;
|
|
507
|
+
const overKey = String(over.id);
|
|
508
|
+
const nextAfter = (index: number) => keys.slice(index + 1).find((k) => k !== activeKey) ?? null;
|
|
509
|
+
if (kindOf(overKey) === "z" && rawOf(overKey) === UNGROUPED) {
|
|
510
|
+
setTarget({ before: null });
|
|
511
|
+
} else if (overKey === activeKey) {
|
|
512
|
+
setTarget({ before: nextAfter(keys.indexOf(activeKey)) });
|
|
513
|
+
} else if (keys.includes(overKey)) {
|
|
514
|
+
if (kindOf(activeKey) === "i" && kindOf(overKey) === "g") {
|
|
515
|
+
// รายการชี้ครึ่งบนของหัวกลุ่ม = วางก่อนกลุ่มนั้น
|
|
516
|
+
setTarget({ before: overKey });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const a = keys.indexOf(activeKey);
|
|
520
|
+
const o = keys.indexOf(overKey);
|
|
521
|
+
const rect = measureKey(overKey);
|
|
522
|
+
const dragged = active.rect.current.translated;
|
|
523
|
+
// คีย์บอร์ดขยับทีละตำแหน่งไปทับกลางเป้าหมายพอดี → ใช้ทิศทางแทน
|
|
524
|
+
const after =
|
|
525
|
+
keyboard || !rect || !dragged
|
|
526
|
+
? o > a
|
|
527
|
+
: dragged.top + dragged.height / 2 > (rect.top + rect.bottom) / 2;
|
|
528
|
+
setTarget({ before: after ? nextAfter(o) : overKey });
|
|
529
|
+
} else {
|
|
530
|
+
setTarget(null);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const measuring = useMemo(
|
|
535
|
+
() => ({ droppable: { strategy: MeasuringStrategy.Always, measure: measureDroppable } }),
|
|
536
|
+
[measureDroppable],
|
|
537
|
+
);
|
|
538
|
+
|
|
197
539
|
// ย้ายข้ามกลุ่มเสร็จแล้ว (render ลำดับใหม่ไปแล้ว) ค่อยปลดธงในเฟรมถัดไป
|
|
198
540
|
useEffect(() => {
|
|
199
541
|
const frame = requestAnimationFrame(() => {
|
|
@@ -212,10 +554,16 @@ export function SortableProvider({
|
|
|
212
554
|
*/
|
|
213
555
|
const collisionDetection: CollisionDetection = useCallback((args) => {
|
|
214
556
|
if (kindOf(args.active.id) === "g") {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
557
|
+
// freeLayout: กลุ่มเรียงปนกับรายการไม่มีกลุ่ม → เทียบกับทั้งสองอย่าง (สิ่งที่อยู่ใต้เมาส์ก่อน)
|
|
558
|
+
const loose = new Set(latest.current.items[UNGROUPED] ?? []);
|
|
559
|
+
const rootLevel = args.droppableContainers.filter(
|
|
560
|
+
(c) =>
|
|
561
|
+
kindOf(c.id) === "g" ||
|
|
562
|
+
(freeRef.current && kindOf(c.id) === "i" && loose.has(rawOf(c.id))),
|
|
563
|
+
);
|
|
564
|
+
const scoped = { ...args, droppableContainers: rootLevel };
|
|
565
|
+
const under = freeRef.current ? pointerWithin(scoped) : [];
|
|
566
|
+
return under.length > 0 ? under.slice(0, 1) : closestCenter(scoped);
|
|
219
567
|
}
|
|
220
568
|
const candidates = args.droppableContainers.filter((c) => kindOf(c.id) !== "g");
|
|
221
569
|
const scoped = { ...args, droppableContainers: candidates };
|
|
@@ -223,14 +571,27 @@ export function SortableProvider({
|
|
|
223
571
|
const hits = underPointer.length > 0 ? underPointer : rectIntersection(scoped);
|
|
224
572
|
let overId = getFirstCollision(hits, "id");
|
|
225
573
|
if (overId != null) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
574
|
+
const header = kindOf(overId) === "z" ? args.droppableRects.get(overId) : undefined;
|
|
575
|
+
const pointer = args.pointerCoordinates;
|
|
576
|
+
if (
|
|
577
|
+
freeRef.current &&
|
|
578
|
+
header &&
|
|
579
|
+
pointer &&
|
|
580
|
+
rawOf(overId) !== UNGROUPED &&
|
|
581
|
+
pointer.y < header.top + header.height / 2
|
|
582
|
+
) {
|
|
583
|
+
// freeLayout: ครึ่งบนของหัวกลุ่ม = วาง "ก่อนกลุ่ม" ในชั้นบนสุด (ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก)
|
|
584
|
+
overId = dndId("group", rawOf(overId));
|
|
585
|
+
} else if (kindOf(overId) === "z" && rawOf(overId) !== UNGROUPED) {
|
|
586
|
+
// (โซนของ UNGROUPED = ที่วาง "ท้ายสุด" ไม่ใช่หัวกลุ่ม — ห้ามเปลี่ยนเป็นรายการแรก)
|
|
587
|
+
// ชี้หัวกลุ่ม = "บนสุดของกลุ่ม" → เป้าหมายคือรายการแรกที่แสดงอยู่ "รวมตัวที่ลากเอง"
|
|
588
|
+
// (เดิมเลือกตัวที่ใกล้ที่สุดที่ไม่ใช่ตัวที่ลาก: ลากเข้ากลุ่มแล้วขึ้นเป็นตัวแรกถูก แต่พอปล่อย
|
|
589
|
+
// onDragEnd ย้ายไปแทนที่ตัวแรกเดิม → กลายเป็นตัวที่สอง วางเป็นรายการแรกไม่ได้)
|
|
590
|
+
const rendered = new Set(
|
|
591
|
+
candidates.filter((c) => kindOf(c.id) === "i").map((c) => rawOf(c.id)),
|
|
230
592
|
);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
593
|
+
const first = (latest.current.items[rawOf(overId)] ?? []).find((x) => rendered.has(x));
|
|
594
|
+
if (first !== undefined) overId = dndId("item", first);
|
|
234
595
|
}
|
|
235
596
|
lastOverId.current = overId;
|
|
236
597
|
return [{ id: overId }];
|
|
@@ -253,16 +614,82 @@ export function SortableProvider({
|
|
|
253
614
|
beforeDrag.current = latest.current;
|
|
254
615
|
lastOverId.current = null;
|
|
255
616
|
const kind: SortableKind = kindOf(active.id) === "g" ? "group" : "item";
|
|
617
|
+
const raw = rawOf(active.id);
|
|
618
|
+
const chosen = selectedRef.current;
|
|
619
|
+
bulk.current = null;
|
|
620
|
+
if (kind === "item" && chosen.has(raw) && chosen.size > 1) {
|
|
621
|
+
bulk.current = flatItems(latest.current).filter((x) => chosen.has(x));
|
|
622
|
+
} else if (kind === "item" && chosen.size > 0 && !chosen.has(raw)) {
|
|
623
|
+
// ลากตัวที่ไม่ได้เลือก = ลากตัวนั้นตัวเดียว และล้างการเลือกเดิม (แบบ Finder)
|
|
624
|
+
select("", "clear");
|
|
625
|
+
}
|
|
626
|
+
draggingRef.current = kind;
|
|
627
|
+
setTarget(null);
|
|
256
628
|
setDragging(kind);
|
|
257
|
-
setActiveDrag({ kind, id:
|
|
629
|
+
setActiveDrag({ kind, id: raw, ids: bulk.current ?? [raw] });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function onDragMove({ active, over, delta, activatorEvent }: DragMoveEvent) {
|
|
633
|
+
updateTarget(active, over, activatorEvent instanceof KeyboardEvent);
|
|
634
|
+
// ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ)
|
|
635
|
+
if (overlayOn || kindOf(active.id) !== "g") return;
|
|
636
|
+
shifts.set(String(active.id), {
|
|
637
|
+
transform: `translate3d(${delta.x}px, ${delta.y}px, 0)`,
|
|
638
|
+
transition: undefined,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function endDrag() {
|
|
643
|
+
draggingRef.current = null;
|
|
644
|
+
bulk.current = null;
|
|
645
|
+
setTarget(null);
|
|
646
|
+
// ล้างระยะเลื่อนพร้อมกับที่ลำดับใหม่ render (รอ effect = มีเฟรมที่แถวใหม่ยังติด transform เก่า → กระพริบ)
|
|
647
|
+
shifts.clear();
|
|
648
|
+
setDragging(null);
|
|
649
|
+
setActiveDrag(null);
|
|
258
650
|
}
|
|
259
651
|
|
|
260
|
-
function onDragOver({ active, over }: DragOverEvent) {
|
|
261
|
-
|
|
652
|
+
function onDragOver({ active, over, activatorEvent }: DragOverEvent) {
|
|
653
|
+
const keyboard = activatorEvent instanceof KeyboardEvent;
|
|
654
|
+
if (!over || kindOf(active.id) !== "i") {
|
|
655
|
+
updateTarget(active, over, keyboard);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
262
658
|
const current = latest.current;
|
|
659
|
+
const raw = rawOf(active.id);
|
|
660
|
+
const apply = (next: SortableOrder) => {
|
|
661
|
+
if (next === current) return;
|
|
662
|
+
movedAcross.current = true;
|
|
663
|
+
latest.current = next;
|
|
664
|
+
onOrderChange(next);
|
|
665
|
+
};
|
|
666
|
+
// ครึ่งบนของหัวกลุ่ม (freeLayout) = ออกมาวางก่อนกลุ่มนั้นในชั้นบนสุด
|
|
667
|
+
if (kindOf(over.id) === "g") {
|
|
668
|
+
if (groupOfItem(current, raw) !== UNGROUPED) {
|
|
669
|
+
apply(moveItemToRoot(current, raw, { kind: "group", id: rawOf(over.id) }));
|
|
670
|
+
}
|
|
671
|
+
updateTarget(active, over, keyboard);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
263
674
|
const from = containerOf(active.id, current);
|
|
264
675
|
const to = containerOf(over.id, current);
|
|
265
|
-
if (!from || !to || from === to)
|
|
676
|
+
if (!from || !to || from === to) {
|
|
677
|
+
updateTarget(active, over, keyboard);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (to === UNGROUPED) {
|
|
681
|
+
// ออกจากกลุ่มมาชั้นบนสุด: ก่อน/หลังรายการที่ชี้ตามครึ่งแถว (ถัดไปอาจเป็นกลุ่ม) หรือท้ายสุด
|
|
682
|
+
const root = rootEntries(current);
|
|
683
|
+
let before: (typeof root)[number] | null = null;
|
|
684
|
+
if (kindOf(over.id) === "i") {
|
|
685
|
+
const at = root.findIndex((e) => e.kind === "item" && e.id === rawOf(over.id));
|
|
686
|
+
const dragged = active.rect.current.translated;
|
|
687
|
+
const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
|
|
688
|
+
before = root[at + (below ? 1 : 0)] ?? null;
|
|
689
|
+
}
|
|
690
|
+
apply(moveItemToRoot(current, raw, before));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
266
693
|
// วางก่อนหรือหลังแถวเป้าหมาย ตามว่าตัวที่ลากเลยครึ่งแถวนั้นลงมาหรือยัง
|
|
267
694
|
// (เดิมแทรก "ก่อน" เสมอ — ลากเข้าจากด้านล่างแล้วรายการกระโดดข้ามขึ้นไป)
|
|
268
695
|
let before: string | null = null;
|
|
@@ -272,17 +699,12 @@ export function SortableProvider({
|
|
|
272
699
|
const dragged = active.rect.current.translated;
|
|
273
700
|
const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
|
|
274
701
|
before = list[at + (below ? 1 : 0)] ?? null;
|
|
275
|
-
} else
|
|
702
|
+
} else {
|
|
276
703
|
// วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (ตรงกับตำแหน่งที่เห็น และไม่กระโดดตอนโฟลเดอร์ที่ย่อไว้เปิดออก)
|
|
277
704
|
// ส่วนโซน "ไม่มีกลุ่ม" อยู่ท้ายตาราง จึงต่อท้าย
|
|
278
705
|
before = current.items[to]?.[0] ?? null;
|
|
279
706
|
}
|
|
280
|
-
|
|
281
|
-
if (next !== current) {
|
|
282
|
-
movedAcross.current = true;
|
|
283
|
-
latest.current = next;
|
|
284
|
-
onOrderChange(next);
|
|
285
|
-
}
|
|
707
|
+
apply(moveItemToGroup(current, raw, to, before));
|
|
286
708
|
}
|
|
287
709
|
|
|
288
710
|
/** ล้มเหลว = คืนลำดับเดิมเสมอ แล้วส่ง error ต่อให้แอป (ไม่มี `onCommitError` = ขึ้น toast ให้) */
|
|
@@ -308,16 +730,39 @@ export function SortableProvider({
|
|
|
308
730
|
|
|
309
731
|
function onDragEnd({ active, over }: DragEndEvent) {
|
|
310
732
|
const snapshot = beforeDrag.current;
|
|
733
|
+
const carried = bulk.current;
|
|
734
|
+
// อ่านเป้าหมายก่อน endDrag (endDrag ล้างทิ้ง)
|
|
735
|
+
const finalTarget = targetRef.current;
|
|
311
736
|
beforeDrag.current = null;
|
|
312
|
-
|
|
313
|
-
setActiveDrag(null);
|
|
737
|
+
endDrag();
|
|
314
738
|
const current = latest.current;
|
|
315
739
|
let next = current;
|
|
316
|
-
|
|
317
|
-
|
|
740
|
+
const activeKey = String(active.id);
|
|
741
|
+
const keys = entryKeys(current, activeKey);
|
|
742
|
+
if (keys && finalTarget) {
|
|
743
|
+
// ชั้นบนสุด: แปลง "ก่อน X / ท้ายลำดับ" เป็นตำแหน่งใน root (ลำดับที่ไม่รวมตัวที่ลาก)
|
|
744
|
+
const rootRest = rootEntries(current)
|
|
745
|
+
.map((e) => dndId(e.kind, e.id))
|
|
746
|
+
.filter((k) => k !== activeKey);
|
|
747
|
+
const last = keys.filter((k) => k !== activeKey).at(-1);
|
|
748
|
+
const index =
|
|
749
|
+
finalTarget.before !== null
|
|
750
|
+
? rootRest.indexOf(finalTarget.before)
|
|
751
|
+
: last
|
|
752
|
+
? rootRest.indexOf(last) + 1
|
|
753
|
+
: rootRest.length;
|
|
754
|
+
if (index >= 0) {
|
|
755
|
+
next = moveRootEntry(
|
|
756
|
+
current,
|
|
757
|
+
{ kind: kindOf(activeKey) === "g" ? "group" : "item", id: rawOf(activeKey) },
|
|
758
|
+
index,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
318
761
|
} else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
|
|
319
762
|
next = reorderItems(current, rawOf(active.id), rawOf(over.id));
|
|
320
763
|
}
|
|
764
|
+
// bulk: ตัวที่เลือกไว้ตัวอื่นตามมารวมที่จุดวางของตัวที่จับ (เรียงตามลำดับเดิม)
|
|
765
|
+
if (carried) next = gatherItems(next, rawOf(active.id), carried);
|
|
321
766
|
if (next !== current) {
|
|
322
767
|
latest.current = next;
|
|
323
768
|
onOrderChange(next);
|
|
@@ -328,8 +773,7 @@ export function SortableProvider({
|
|
|
328
773
|
function onDragCancel() {
|
|
329
774
|
const snapshot = beforeDrag.current;
|
|
330
775
|
beforeDrag.current = null;
|
|
331
|
-
|
|
332
|
-
setActiveDrag(null);
|
|
776
|
+
endDrag();
|
|
333
777
|
if (snapshot) {
|
|
334
778
|
latest.current = snapshot;
|
|
335
779
|
onOrderChange(snapshot);
|
|
@@ -351,6 +795,7 @@ export function SortableProvider({
|
|
|
351
795
|
kind: "group",
|
|
352
796
|
index: current.groups.indexOf(raw) + 1,
|
|
353
797
|
total: current.groups.length,
|
|
798
|
+
count: 1,
|
|
354
799
|
};
|
|
355
800
|
}
|
|
356
801
|
const group = groupOfItem(current, raw);
|
|
@@ -361,6 +806,7 @@ export function SortableProvider({
|
|
|
361
806
|
groupId: group,
|
|
362
807
|
index: list.indexOf(raw) + 1,
|
|
363
808
|
total: list.length,
|
|
809
|
+
count: bulk.current?.length ?? 1,
|
|
364
810
|
};
|
|
365
811
|
}, []);
|
|
366
812
|
|
|
@@ -397,12 +843,15 @@ export function SortableProvider({
|
|
|
397
843
|
) =>
|
|
398
844
|
(template ?? fallback)
|
|
399
845
|
.replace("{index}", String(ctx.index))
|
|
400
|
-
.replace("{total}", String(ctx.total))
|
|
846
|
+
.replace("{total}", String(ctx.total))
|
|
847
|
+
.replace("{count}", String(ctx.count));
|
|
401
848
|
const announce =
|
|
402
849
|
(
|
|
403
850
|
own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
|
|
404
851
|
template: string | undefined,
|
|
405
852
|
fallback: string,
|
|
853
|
+
// ประโยคแทนเมื่อลากหลายรายการพร้อมกัน
|
|
854
|
+
many: { template: string | undefined; fallback: string } | null,
|
|
406
855
|
// ระหว่างลาก = ตำแหน่งของเป้าหมาย; ตอนปล่อย order อัปเดตแล้ว จึงใช้ตำแหน่งจริงของตัวที่ลาก
|
|
407
856
|
useTarget = false,
|
|
408
857
|
) =>
|
|
@@ -416,39 +865,92 @@ export function SortableProvider({
|
|
|
416
865
|
const context = useTarget
|
|
417
866
|
? describeTarget(active.id, over?.id ?? null)
|
|
418
867
|
: describe(active.id);
|
|
419
|
-
|
|
868
|
+
if (own) {
|
|
869
|
+
const said = own(context);
|
|
870
|
+
if (said !== undefined) return said;
|
|
871
|
+
}
|
|
872
|
+
return many && context.count > 1
|
|
873
|
+
? fill(many.template, many.fallback, context)
|
|
874
|
+
: fill(template, fallback, context);
|
|
420
875
|
};
|
|
421
876
|
return {
|
|
422
877
|
onDragStart: announce(
|
|
423
878
|
announcements?.onDragStart,
|
|
424
879
|
text.sortable?.started,
|
|
425
880
|
"เริ่มลากรายการที่ {index} จาก {total}",
|
|
881
|
+
{ template: text.sortable?.startedMany, fallback: "เริ่มลาก {count} รายการที่เลือกไว้" },
|
|
426
882
|
),
|
|
427
883
|
onDragOver: announce(
|
|
428
884
|
announcements?.onDragOver,
|
|
429
885
|
text.sortable?.moved,
|
|
430
886
|
"ย้ายไปตำแหน่งที่ {index} จาก {total}",
|
|
887
|
+
null,
|
|
431
888
|
true,
|
|
432
889
|
),
|
|
433
890
|
onDragEnd: announce(
|
|
434
891
|
announcements?.onDragEnd,
|
|
435
892
|
text.sortable?.dropped,
|
|
436
893
|
"วางที่ตำแหน่งที่ {index} จาก {total}",
|
|
894
|
+
null,
|
|
895
|
+
),
|
|
896
|
+
onDragCancel: announce(
|
|
897
|
+
announcements?.onDragCancel,
|
|
898
|
+
text.sortable?.cancelled,
|
|
899
|
+
"ยกเลิกการลาก",
|
|
900
|
+
null,
|
|
437
901
|
),
|
|
438
|
-
onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
|
|
439
902
|
};
|
|
440
903
|
}, [announcements, describeTarget, text.sortable]);
|
|
441
904
|
|
|
442
905
|
const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
|
|
443
906
|
|
|
907
|
+
const groupOf = useCallback((itemId: string) => groupOfItem(order, itemId), [order]);
|
|
908
|
+
const rootDrag =
|
|
909
|
+
activeDrag !== null &&
|
|
910
|
+
(activeDrag.kind === "group" ||
|
|
911
|
+
(freeLayout && groupOfItem(order, activeDrag.id) === UNGROUPED));
|
|
912
|
+
const carried = useMemo<ReadonlySet<string>>(
|
|
913
|
+
() =>
|
|
914
|
+
activeDrag && activeDrag.ids.length > 1
|
|
915
|
+
? new Set(activeDrag.ids.filter((x) => x !== activeDrag.id))
|
|
916
|
+
: EMPTY,
|
|
917
|
+
[activeDrag],
|
|
918
|
+
);
|
|
919
|
+
|
|
444
920
|
const state = useMemo<SortableState>(
|
|
445
921
|
() => ({
|
|
446
922
|
dragging,
|
|
447
923
|
disabled,
|
|
448
924
|
handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
|
|
449
925
|
overlay: Boolean(renderOverlay),
|
|
926
|
+
activeGroup: activeDrag?.kind === "group" ? activeDrag.id : null,
|
|
927
|
+
rootDrag,
|
|
928
|
+
freeLayout,
|
|
929
|
+
carried,
|
|
930
|
+
multiSelect,
|
|
931
|
+
selected,
|
|
932
|
+
select,
|
|
933
|
+
groupOf,
|
|
934
|
+
register,
|
|
935
|
+
shifts,
|
|
450
936
|
}),
|
|
451
|
-
[
|
|
937
|
+
[
|
|
938
|
+
dragging,
|
|
939
|
+
disabled,
|
|
940
|
+
handleLabel,
|
|
941
|
+
text.sortable?.handle,
|
|
942
|
+
renderOverlay,
|
|
943
|
+
activeDrag,
|
|
944
|
+
rootDrag,
|
|
945
|
+
freeLayout,
|
|
946
|
+
carried,
|
|
947
|
+
multiSelect,
|
|
948
|
+
selected,
|
|
949
|
+
select,
|
|
950
|
+
groupOf,
|
|
951
|
+
register,
|
|
952
|
+
shifts,
|
|
953
|
+
],
|
|
452
954
|
);
|
|
453
955
|
|
|
454
956
|
return (
|
|
@@ -458,13 +960,15 @@ export function SortableProvider({
|
|
|
458
960
|
sensors={sensors}
|
|
459
961
|
collisionDetection={collisionDetection}
|
|
460
962
|
// วัดตำแหน่งที่วางใหม่ตลอดระหว่างลาก — ย้ายข้ามกลุ่มแล้ว layout เปลี่ยน ต้องใช้ตำแหน่งปัจจุบัน
|
|
461
|
-
|
|
963
|
+
// (ตอนลากกลุ่ม วัดหัวกลุ่มรวมแถวรายการทั้งก้อน — ดู measureDroppable)
|
|
964
|
+
measuring={measuring}
|
|
462
965
|
accessibility={{
|
|
463
966
|
screenReaderInstructions: { draggable: instructions },
|
|
464
967
|
announcements: dndAnnouncements,
|
|
465
968
|
}}
|
|
466
969
|
onDragStart={onDragStart}
|
|
467
970
|
onDragOver={onDragOver}
|
|
971
|
+
onDragMove={onDragMove}
|
|
468
972
|
onDragEnd={onDragEnd}
|
|
469
973
|
onDragCancel={onDragCancel}
|
|
470
974
|
>
|
|
@@ -511,8 +1015,18 @@ export interface SortableRow {
|
|
|
511
1015
|
/** props ที่ต้องแปะที่ "ที่จับ" — ส่งต่อให้ `<DragHandle {...handleProps} />` */
|
|
512
1016
|
handleProps: Record<string, unknown>;
|
|
513
1017
|
isDragging: boolean;
|
|
1018
|
+
/** รายการนี้ถูกเลือกอยู่ (`multiSelect`) */
|
|
1019
|
+
selected: boolean;
|
|
1020
|
+
/**
|
|
1021
|
+
* กระจายลงแถวเมื่อเปิด `multiSelect`: คลิกพร้อม ⌘/Ctrl/Shift เพื่อเลือก + `data-selected`
|
|
1022
|
+
* สำหรับทำสี (`data-[selected]:bg-primary/10`) — ปิด `multiSelect` หรือเป็นแถวกลุ่ม = object ว่าง
|
|
1023
|
+
*/
|
|
1024
|
+
selectProps: Record<string, unknown>;
|
|
514
1025
|
}
|
|
515
1026
|
|
|
1027
|
+
const INTERACTIVE =
|
|
1028
|
+
"a,button,input,select,textarea,label,[contenteditable=''],[contenteditable='true'],[role=button],[role=checkbox],[role=switch],[role=combobox]";
|
|
1029
|
+
|
|
516
1030
|
/**
|
|
517
1031
|
* ทำให้แถวหนึ่งลากได้ — แอปเอา `ref`/`style` ไปแปะที่ `<TableRow>` และ `handleProps` ที่ `<DragHandle>`
|
|
518
1032
|
* `type: "group"` = ลากทั้งกลุ่ม, `"item"` = ลากรายการในกลุ่ม
|
|
@@ -533,21 +1047,117 @@ export function useSortableRow({
|
|
|
533
1047
|
disabled: disabled || state.disabled,
|
|
534
1048
|
transition: SORT_TRANSITION,
|
|
535
1049
|
});
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
1050
|
+
const { register, shifts, select } = state;
|
|
1051
|
+
const { setNodeRef } = sortable;
|
|
1052
|
+
const ref = useCallback(
|
|
1053
|
+
(node: HTMLElement | null) => {
|
|
1054
|
+
setNodeRef(node);
|
|
1055
|
+
register(type, id, node);
|
|
1056
|
+
},
|
|
1057
|
+
[setNodeRef, register, type, id],
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
const transform = CSS.Translate.toString(sortable.transform);
|
|
1061
|
+
// ระหว่างลากกลุ่ม provider คำนวณระยะเลื่อนของแต่ละกลุ่มเอง — หัวกลุ่มและแถวรายการของกลุ่มอ่านค่าเดียวกัน
|
|
1062
|
+
// (หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงคำนวณให้หัวกลุ่มไม่ได้)
|
|
1063
|
+
const groupId = type === "item" ? state.groupOf(id) : id;
|
|
1064
|
+
const loose = type === "item" && groupId === UNGROUPED;
|
|
1065
|
+
// ระยะเลื่อนในชั้นบนสุด: กลุ่ม/รายการไม่มีกลุ่ม อ่านของตัวเอง, รายการในกลุ่มอ่านของกลุ่ม
|
|
1066
|
+
const shiftKey =
|
|
1067
|
+
type === "group" || loose ? dndId(type, id) : groupId ? dndId("group", groupId) : undefined;
|
|
1068
|
+
const shift = useSyncExternalStore(
|
|
1069
|
+
shifts.subscribe,
|
|
1070
|
+
() => (shiftKey ? shifts.get(shiftKey) : undefined),
|
|
1071
|
+
() => undefined,
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
let style: CSSProperties = { transform, transition: sortable.transition };
|
|
1075
|
+
if (loose && state.freeLayout && state.rootDrag) {
|
|
1076
|
+
style =
|
|
1077
|
+
sortable.isDragging && !state.overlay
|
|
1078
|
+
? { ...style, position: "relative", zIndex: 1, opacity: 0.85 }
|
|
1079
|
+
: {
|
|
1080
|
+
transform: shift?.transform,
|
|
1081
|
+
transition: shift?.transition,
|
|
1082
|
+
...(sortable.isDragging || state.carried.has(id) ? { opacity: 0.4 } : {}),
|
|
1083
|
+
};
|
|
1084
|
+
} else if (type === "group" && state.rootDrag) {
|
|
1085
|
+
const own = state.activeGroup === id;
|
|
1086
|
+
style = {
|
|
1087
|
+
transform: shift?.transform,
|
|
1088
|
+
transition: shift?.transition,
|
|
1089
|
+
...(own
|
|
1090
|
+
? state.overlay
|
|
1091
|
+
? { opacity: 0.4 }
|
|
1092
|
+
: { position: "relative", zIndex: 1, opacity: 0.85 }
|
|
1093
|
+
: {}),
|
|
1094
|
+
};
|
|
1095
|
+
} else if (sortable.isDragging) {
|
|
1096
|
+
style = state.overlay
|
|
539
1097
|
? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
|
|
540
|
-
{ opacity: 0.4 }
|
|
541
|
-
: { position: "relative", zIndex: 1, opacity: 0.85 };
|
|
1098
|
+
{ ...style, opacity: 0.4 }
|
|
1099
|
+
: { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
|
|
1100
|
+
} else if (type === "item" && state.rootDrag && shift) {
|
|
1101
|
+
const own = state.activeGroup !== null && state.activeGroup === groupId;
|
|
1102
|
+
style = {
|
|
1103
|
+
transform: shift.transform,
|
|
1104
|
+
transition: shift.transition,
|
|
1105
|
+
...(own
|
|
1106
|
+
? state.overlay
|
|
1107
|
+
? { opacity: 0.4 }
|
|
1108
|
+
: { position: "relative", zIndex: 1, opacity: 0.85 }
|
|
1109
|
+
: {}),
|
|
1110
|
+
};
|
|
1111
|
+
} else if (type === "item" && state.carried.has(id)) {
|
|
1112
|
+
// ลากแบบ bulk: ตัวที่ตามไปด้วยจางลงตรงที่เดิม แล้วไปรวมที่จุดวางตอนปล่อย
|
|
1113
|
+
style = { ...style, opacity: 0.4 };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const selectable = type === "item" && state.multiSelect;
|
|
1117
|
+
const isSelected = selectable && state.selected.has(id);
|
|
1118
|
+
const listeners = sortable.listeners;
|
|
1119
|
+
const handleProps: Record<string, unknown> = { ...sortable.attributes, ...listeners };
|
|
1120
|
+
if (selectable) {
|
|
1121
|
+
handleProps.onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
1122
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
1123
|
+
if (!mod && !event.altKey && event.key.toLowerCase() === "x") {
|
|
1124
|
+
event.preventDefault();
|
|
1125
|
+
select(id, "toggle");
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
if (mod && event.key.toLowerCase() === "a") {
|
|
1129
|
+
event.preventDefault();
|
|
1130
|
+
select(id, "all");
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
listeners?.onKeyDown?.(event);
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
const selectProps: Record<string, unknown> = selectable
|
|
1137
|
+
? {
|
|
1138
|
+
"data-selected": isSelected ? "" : undefined,
|
|
1139
|
+
onMouseDown: (event: MouseEvent<HTMLElement>) => {
|
|
1140
|
+
// Shift+คลิก ไม่ให้เบราว์เซอร์ลากคลุมข้อความทั้งแถว
|
|
1141
|
+
if (event.shiftKey) event.preventDefault();
|
|
1142
|
+
},
|
|
1143
|
+
onClick: (event: MouseEvent<HTMLElement>) => {
|
|
1144
|
+
const target = event.target as Element;
|
|
1145
|
+
const control = target.closest(INTERACTIVE);
|
|
1146
|
+
if (control && !control.closest("[data-sortable-handle]")) return;
|
|
1147
|
+
if (event.metaKey || event.ctrlKey) select(id, "toggle");
|
|
1148
|
+
else if (event.shiftKey) select(id, "range");
|
|
1149
|
+
else if (state.selected.size > 0) select(id, "clear");
|
|
1150
|
+
},
|
|
1151
|
+
}
|
|
1152
|
+
: {};
|
|
1153
|
+
|
|
542
1154
|
return {
|
|
543
|
-
ref
|
|
544
|
-
style
|
|
545
|
-
|
|
546
|
-
transition: sortable.transition,
|
|
547
|
-
...dragStyle,
|
|
548
|
-
},
|
|
549
|
-
handleProps: { ...sortable.attributes, ...sortable.listeners },
|
|
1155
|
+
ref,
|
|
1156
|
+
style,
|
|
1157
|
+
handleProps,
|
|
550
1158
|
isDragging: sortable.isDragging,
|
|
1159
|
+
selected: isSelected,
|
|
1160
|
+
selectProps,
|
|
551
1161
|
};
|
|
552
1162
|
}
|
|
553
1163
|
|
|
@@ -617,6 +1227,7 @@ export function DragHandle({
|
|
|
617
1227
|
aria-label={label ?? state.handleLabel}
|
|
618
1228
|
disabled={off}
|
|
619
1229
|
className={cn("cursor-grab touch-none active:cursor-grabbing", className)}
|
|
1230
|
+
data-sortable-handle=""
|
|
620
1231
|
{...handleProps}
|
|
621
1232
|
>
|
|
622
1233
|
<GripVerticalIcon />
|