@suphark/ui 0.38.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 +21 -1
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/components/sortable/sortable-list.tsx +254 -71
- package/src/lib/sortable-order.ts +170 -33
- package/src/sortable.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.39.0] — 2026-10-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`SortableProvider freeLayout` — ชั้นบนสุดจัดได้อิสระ** (เจ้าของแจ้งว่าเดิม "ล็อก" ให้รายการไม่มีกลุ่มอยู่บนเสมอ กลุ่มอยู่ล่างเสมอ): กลุ่มกับรายการไม่มีกลุ่มสลับที่กันได้ทุกแบบ — กลุ่มขึ้นไปเหนือรายการ, รายการไปอยู่ระหว่างกลุ่ม, เอารายการออกจากกลุ่มไปวางก่อนกลุ่มไหนก็ได้ · หัวกลุ่ม: **ครึ่งบน = วางก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก** · ระหว่างลากทุกอย่างในชั้นบนสุดเลื่อนหลบเป็นก้อน (กลุ่ม = หัว + รายการ) · โซนท้ายตาราง = วางท้ายสุด · ไม่เปิด = แบบเดิม (แอปที่ยังเรนเดอร์รายการไม่มีกลุ่มก่อนไม่เปลี่ยนพฤติกรรม)
|
|
10
|
+
- `SortableOrder.root` (optional) + `rootEntries(order)` (ลำดับชั้นบนสุดสำหรับวนเรนเดอร์), `moveRootEntry`, `moveItemToRoot`, type `SortableRootEntry` · `sortableOrderToPayload(order).root` + `sortableOrderOf(..., { root })` สำหรับบันทึก/โหลด — `groups` / `items[UNGROUPED]` ยังตรงกับ root เสมอ (payload เดิมใช้ต่อได้)
|
|
11
|
+
- showcase: `/components/sortable-list` เปิด `freeLayout` และเรนเดอร์ตาม `rootEntries`
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- (จาก 0.38.1 ที่ไม่ได้ขึ้น npm แยก) ลากรายการเข้ากลุ่มแล้ววางเป็นรายการแรกไม่ได้ — ดูด้านล่าง
|
|
16
|
+
- โซน "ท้ายตาราง" (`useSortableDropZone(UNGROUPED)`) ถูก collision เปลี่ยนเป้าหมายเป็นรายการไม่มีกลุ่มตัวแรก → วางแล้วไปโผล่บนสุดแทนท้ายสุด
|
|
17
|
+
|
|
18
|
+
## [0.38.1] — 2026-10-07
|
|
19
|
+
|
|
20
|
+
> ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.39.0
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **`@suphark/ui/sortable` ลากรายการเข้ากลุ่มแล้ววางเป็นรายการแรกไม่ได้** (เจ้าของแจ้ง): ระหว่างลากขึ้นเป็นตัวแรกถูก แต่พอปล่อยกลับไปเป็นตัวที่สอง — ตอนชี้หัวกลุ่ม collision เลือก "รายการที่ใกล้ที่สุดที่ไม่ใช่ตัวที่ลาก" (= ตัวแรกเดิม) แล้ว `onDragEnd` ย้ายไปแทนที่ตัวนั้น · ตอนนี้ชี้หัวกลุ่ม = รายการแรกที่แสดงอยู่ **รวมตัวที่ลากเอง** · ยืนยันบน demo: ลากจากนอกกลุ่ม / จากกลุ่มอื่น / จากท้ายกลุ่มเดียวกันขึ้นหัวกลุ่ม → เป็นตัวแรกทุกกรณี, วางครึ่งล่างของแถวยังไปอยู่ "หลัง" แถวนั้นตามเดิม (ทำซ้ำบน production v0.38.0 ได้ก่อนแก้)
|
|
25
|
+
|
|
6
26
|
|
|
7
27
|
### Fixed
|
|
8
28
|
|
package/README.md
CHANGED
|
@@ -235,6 +235,7 @@ const [order, setOrder] = useState(() =>
|
|
|
235
235
|
- `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
|
|
236
236
|
- การ์ดลอยตามเมาส์: `renderOverlay={(drag) => drag.kind === "item" ? <ItemCard id={drag.id} /> : <GroupCard id={drag.id} />}` (วาดใน portal จึงเป็น `div` ได้ ไม่ต้องเป็น `<tr>`) — ไม่ใส่ = แถวขยับเองแบบเดิม · โฟลเดอร์ที่ย่ออยู่: `useSortableDropZone(id, { onDwell: collapsed ? open : undefined })` ลากค้าง 600ms แล้วเปิดให้ · วางบนหัวกลุ่ม = ขึ้นบนสุดของกลุ่ม, วางบนโซนของ `UNGROUPED` = ต่อท้าย
|
|
237
237
|
- เลือกหลายรายการแล้วลากพร้อมกัน: `SortableProvider multiSelect` แล้วกระจาย `row.selectProps` ลงแถว (ทำสีด้วย `data-[selected]:bg-primary/10`) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกด `x` / ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้ `SelectionBar` + `useSortableState().selected/clearSelection` · `renderOverlay` ได้ `drag.ids` ไว้ทำการ์ดซ้อน
|
|
238
|
+
- จัดชั้นบนสุดอิสระ (กลุ่มกับรายการไม่มีกลุ่มสลับกันได้): `SortableProvider freeLayout` + เรนเดอร์ตาม `rootEntries(order)` (ครอบทั้งหมดด้วย `<SortableGroup itemIds={order.items[UNGROUPED]}>` แล้วแต่ละกลุ่มมี `SortableGroup` ของตัวเองข้างใน) + บันทึก `payload.root` และส่งกลับ `sortableOrderOf(..., { root })` ตอนโหลด · หัวกลุ่มครึ่งบน = ก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่ม
|
|
238
239
|
- `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
|
|
239
240
|
- `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
|
|
240
241
|
- ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -38,8 +38,10 @@ import {
|
|
|
38
38
|
gatherItems,
|
|
39
39
|
groupOfItem,
|
|
40
40
|
moveItemToGroup,
|
|
41
|
-
|
|
41
|
+
moveItemToRoot,
|
|
42
|
+
moveRootEntry,
|
|
42
43
|
reorderItems,
|
|
44
|
+
rootEntries,
|
|
43
45
|
type SortableOrder,
|
|
44
46
|
sameOrder,
|
|
45
47
|
UNGROUPED,
|
|
@@ -110,6 +112,11 @@ function createShiftStore() {
|
|
|
110
112
|
else shifts.delete(id);
|
|
111
113
|
for (const listener of listeners) listener();
|
|
112
114
|
},
|
|
115
|
+
clear() {
|
|
116
|
+
if (shifts.size === 0) return;
|
|
117
|
+
shifts.clear();
|
|
118
|
+
for (const listener of listeners) listener();
|
|
119
|
+
},
|
|
113
120
|
subscribe(listener: () => void) {
|
|
114
121
|
listeners.add(listener);
|
|
115
122
|
return () => {
|
|
@@ -129,6 +136,9 @@ interface SortableState {
|
|
|
129
136
|
overlay: boolean;
|
|
130
137
|
/** กลุ่มที่กำลังถูกลาก (แถวรายการของกลุ่มนี้จางตามหัวกลุ่ม) */
|
|
131
138
|
activeGroup: string | null;
|
|
139
|
+
/** กำลังลากของในชั้นบนสุด (กลุ่ม หรือรายการไม่มีกลุ่มเมื่อ `freeLayout`) — ระยะเลื่อนมาจาก provider */
|
|
140
|
+
rootDrag: boolean;
|
|
141
|
+
freeLayout: boolean;
|
|
132
142
|
/** รายการอื่นที่ลากไปพร้อมกันแบบ bulk (ไม่รวมตัวที่จับ) — แสดงจาง ๆ ระหว่างลาก */
|
|
133
143
|
carried: ReadonlySet<string>;
|
|
134
144
|
multiSelect: boolean;
|
|
@@ -146,6 +156,8 @@ const SortableStateContext = createContext<SortableState>({
|
|
|
146
156
|
handleLabel: "ลากเพื่อจัดลำดับ",
|
|
147
157
|
overlay: false,
|
|
148
158
|
activeGroup: null,
|
|
159
|
+
rootDrag: false,
|
|
160
|
+
freeLayout: false,
|
|
149
161
|
carried: EMPTY,
|
|
150
162
|
multiSelect: false,
|
|
151
163
|
selected: EMPTY,
|
|
@@ -240,6 +252,13 @@ export interface SortableProviderProps {
|
|
|
240
252
|
* ลากตัวที่เลือกอยู่ = ลากทุกตัวที่เลือกไปรวมที่จุดวาง เรียงตามลำดับเดิม
|
|
241
253
|
*/
|
|
242
254
|
multiSelect?: boolean;
|
|
255
|
+
/**
|
|
256
|
+
* ชั้นบนสุดจัดได้อิสระ: กลุ่มกับรายการที่ไม่มีกลุ่มสลับที่กันได้ทุกแบบ (กลุ่มขึ้นไปเหนือรายการ /
|
|
257
|
+
* รายการไปอยู่ระหว่างกลุ่ม / เอารายการออกจากกลุ่มไปวางก่อนกลุ่มไหนก็ได้) — แอปต้องเรนเดอร์ตาม
|
|
258
|
+
* `rootEntries(order)` และบันทึก `sortableOrderToPayload(order).root` · ชี้ครึ่งบนของหัวกลุ่ม = วางก่อนกลุ่ม,
|
|
259
|
+
* ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก · ไม่เปิด = รายการไม่มีกลุ่มอยู่บนสุดเสมอ (แบบเดิม)
|
|
260
|
+
*/
|
|
261
|
+
freeLayout?: boolean;
|
|
243
262
|
/** รายการที่เลือก (ควบคุมจากภายนอก) — ไม่ส่ง = provider เก็บเอง */
|
|
244
263
|
selected?: string[];
|
|
245
264
|
onSelectedChange?: (ids: string[]) => void;
|
|
@@ -267,6 +286,7 @@ export function SortableProvider({
|
|
|
267
286
|
id,
|
|
268
287
|
renderOverlay,
|
|
269
288
|
multiSelect = false,
|
|
289
|
+
freeLayout = false,
|
|
270
290
|
selected: selectedProp,
|
|
271
291
|
onSelectedChange,
|
|
272
292
|
children,
|
|
@@ -395,40 +415,122 @@ export function SortableProvider({
|
|
|
395
415
|
[blockRect],
|
|
396
416
|
);
|
|
397
417
|
|
|
398
|
-
// ----------
|
|
399
|
-
const
|
|
400
|
-
|
|
418
|
+
// ---------- เลื่อนทั้งก้อนในชั้นบนสุด (แบบรายการ: ตัวอื่นหลบ ช่องว่างของตัวที่ลากเลื่อนไปจุดวาง) ----------
|
|
419
|
+
const freeRef = useRef(freeLayout);
|
|
420
|
+
freeRef.current = freeLayout;
|
|
401
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
|
+
|
|
402
459
|
useEffect(() => {
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
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();
|
|
407
466
|
return;
|
|
408
467
|
}
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
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;
|
|
416
481
|
const transition = `transform ${SORT_TRANSITION.duration}ms ${EASING}`;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
+
|
|
432
534
|
const measuring = useMemo(
|
|
433
535
|
() => ({ droppable: { strategy: MeasuringStrategy.Always, measure: measureDroppable } }),
|
|
434
536
|
[measureDroppable],
|
|
@@ -452,10 +554,16 @@ export function SortableProvider({
|
|
|
452
554
|
*/
|
|
453
555
|
const collisionDetection: CollisionDetection = useCallback((args) => {
|
|
454
556
|
if (kindOf(args.active.id) === "g") {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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);
|
|
459
567
|
}
|
|
460
568
|
const candidates = args.droppableContainers.filter((c) => kindOf(c.id) !== "g");
|
|
461
569
|
const scoped = { ...args, droppableContainers: candidates };
|
|
@@ -463,14 +571,27 @@ export function SortableProvider({
|
|
|
463
571
|
const hits = underPointer.length > 0 ? underPointer : rectIntersection(scoped);
|
|
464
572
|
let overId = getFirstCollision(hits, "id");
|
|
465
573
|
if (overId != null) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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)),
|
|
470
592
|
);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
593
|
+
const first = (latest.current.items[rawOf(overId)] ?? []).find((x) => rendered.has(x));
|
|
594
|
+
if (first !== undefined) overId = dndId("item", first);
|
|
474
595
|
}
|
|
475
596
|
lastOverId.current = overId;
|
|
476
597
|
return [{ id: overId }];
|
|
@@ -503,21 +624,16 @@ export function SortableProvider({
|
|
|
503
624
|
select("", "clear");
|
|
504
625
|
}
|
|
505
626
|
draggingRef.current = kind;
|
|
506
|
-
|
|
507
|
-
blocks.current.clear();
|
|
508
|
-
for (const [groupId, header] of nodes.current.groups) {
|
|
509
|
-
if (header.isConnected) blocks.current.set(groupId, blockRect(groupId, header));
|
|
510
|
-
}
|
|
511
|
-
setOverGroup(raw);
|
|
512
|
-
}
|
|
627
|
+
setTarget(null);
|
|
513
628
|
setDragging(kind);
|
|
514
629
|
setActiveDrag({ kind, id: raw, ids: bulk.current ?? [raw] });
|
|
515
630
|
}
|
|
516
631
|
|
|
517
|
-
|
|
518
|
-
|
|
632
|
+
function onDragMove({ active, over, delta, activatorEvent }: DragMoveEvent) {
|
|
633
|
+
updateTarget(active, over, activatorEvent instanceof KeyboardEvent);
|
|
634
|
+
// ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ)
|
|
519
635
|
if (overlayOn || kindOf(active.id) !== "g") return;
|
|
520
|
-
shifts.set(
|
|
636
|
+
shifts.set(String(active.id), {
|
|
521
637
|
transform: `translate3d(${delta.x}px, ${delta.y}px, 0)`,
|
|
522
638
|
transition: undefined,
|
|
523
639
|
});
|
|
@@ -526,23 +642,54 @@ export function SortableProvider({
|
|
|
526
642
|
function endDrag() {
|
|
527
643
|
draggingRef.current = null;
|
|
528
644
|
bulk.current = null;
|
|
529
|
-
|
|
645
|
+
setTarget(null);
|
|
530
646
|
// ล้างระยะเลื่อนพร้อมกับที่ลำดับใหม่ render (รอ effect = มีเฟรมที่แถวใหม่ยังติด transform เก่า → กระพริบ)
|
|
531
|
-
|
|
647
|
+
shifts.clear();
|
|
532
648
|
setDragging(null);
|
|
533
649
|
setActiveDrag(null);
|
|
534
650
|
}
|
|
535
651
|
|
|
536
|
-
function onDragOver({ active, over }: DragOverEvent) {
|
|
537
|
-
|
|
538
|
-
|
|
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);
|
|
539
656
|
return;
|
|
540
657
|
}
|
|
541
|
-
if (!over || kindOf(active.id) !== "i") return;
|
|
542
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
|
+
}
|
|
543
674
|
const from = containerOf(active.id, current);
|
|
544
675
|
const to = containerOf(over.id, current);
|
|
545
|
-
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
|
+
}
|
|
546
693
|
// วางก่อนหรือหลังแถวเป้าหมาย ตามว่าตัวที่ลากเลยครึ่งแถวนั้นลงมาหรือยัง
|
|
547
694
|
// (เดิมแทรก "ก่อน" เสมอ — ลากเข้าจากด้านล่างแล้วรายการกระโดดข้ามขึ้นไป)
|
|
548
695
|
let before: string | null = null;
|
|
@@ -552,17 +699,12 @@ export function SortableProvider({
|
|
|
552
699
|
const dragged = active.rect.current.translated;
|
|
553
700
|
const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
|
|
554
701
|
before = list[at + (below ? 1 : 0)] ?? null;
|
|
555
|
-
} else
|
|
702
|
+
} else {
|
|
556
703
|
// วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (ตรงกับตำแหน่งที่เห็น และไม่กระโดดตอนโฟลเดอร์ที่ย่อไว้เปิดออก)
|
|
557
704
|
// ส่วนโซน "ไม่มีกลุ่ม" อยู่ท้ายตาราง จึงต่อท้าย
|
|
558
705
|
before = current.items[to]?.[0] ?? null;
|
|
559
706
|
}
|
|
560
|
-
|
|
561
|
-
if (next !== current) {
|
|
562
|
-
movedAcross.current = true;
|
|
563
|
-
latest.current = next;
|
|
564
|
-
onOrderChange(next);
|
|
565
|
-
}
|
|
707
|
+
apply(moveItemToGroup(current, raw, to, before));
|
|
566
708
|
}
|
|
567
709
|
|
|
568
710
|
/** ล้มเหลว = คืนลำดับเดิมเสมอ แล้วส่ง error ต่อให้แอป (ไม่มี `onCommitError` = ขึ้น toast ให้) */
|
|
@@ -589,12 +731,33 @@ export function SortableProvider({
|
|
|
589
731
|
function onDragEnd({ active, over }: DragEndEvent) {
|
|
590
732
|
const snapshot = beforeDrag.current;
|
|
591
733
|
const carried = bulk.current;
|
|
734
|
+
// อ่านเป้าหมายก่อน endDrag (endDrag ล้างทิ้ง)
|
|
735
|
+
const finalTarget = targetRef.current;
|
|
592
736
|
beforeDrag.current = null;
|
|
593
737
|
endDrag();
|
|
594
738
|
const current = latest.current;
|
|
595
739
|
let next = current;
|
|
596
|
-
|
|
597
|
-
|
|
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
|
+
}
|
|
598
761
|
} else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
|
|
599
762
|
next = reorderItems(current, rawOf(active.id), rawOf(over.id));
|
|
600
763
|
}
|
|
@@ -742,6 +905,10 @@ export function SortableProvider({
|
|
|
742
905
|
const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
|
|
743
906
|
|
|
744
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));
|
|
745
912
|
const carried = useMemo<ReadonlySet<string>>(
|
|
746
913
|
() =>
|
|
747
914
|
activeDrag && activeDrag.ids.length > 1
|
|
@@ -757,6 +924,8 @@ export function SortableProvider({
|
|
|
757
924
|
handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
|
|
758
925
|
overlay: Boolean(renderOverlay),
|
|
759
926
|
activeGroup: activeDrag?.kind === "group" ? activeDrag.id : null,
|
|
927
|
+
rootDrag,
|
|
928
|
+
freeLayout,
|
|
760
929
|
carried,
|
|
761
930
|
multiSelect,
|
|
762
931
|
selected,
|
|
@@ -772,6 +941,8 @@ export function SortableProvider({
|
|
|
772
941
|
text.sortable?.handle,
|
|
773
942
|
renderOverlay,
|
|
774
943
|
activeDrag,
|
|
944
|
+
rootDrag,
|
|
945
|
+
freeLayout,
|
|
775
946
|
carried,
|
|
776
947
|
multiSelect,
|
|
777
948
|
selected,
|
|
@@ -887,18 +1058,30 @@ export function useSortableRow({
|
|
|
887
1058
|
);
|
|
888
1059
|
|
|
889
1060
|
const transform = CSS.Translate.toString(sortable.transform);
|
|
890
|
-
const groupDrag = state.dragging === "group";
|
|
891
1061
|
// ระหว่างลากกลุ่ม provider คำนวณระยะเลื่อนของแต่ละกลุ่มเอง — หัวกลุ่มและแถวรายการของกลุ่มอ่านค่าเดียวกัน
|
|
892
1062
|
// (หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงคำนวณให้หัวกลุ่มไม่ได้)
|
|
893
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;
|
|
894
1068
|
const shift = useSyncExternalStore(
|
|
895
1069
|
shifts.subscribe,
|
|
896
|
-
() => (
|
|
1070
|
+
() => (shiftKey ? shifts.get(shiftKey) : undefined),
|
|
897
1071
|
() => undefined,
|
|
898
1072
|
);
|
|
899
1073
|
|
|
900
1074
|
let style: CSSProperties = { transform, transition: sortable.transition };
|
|
901
|
-
if (
|
|
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) {
|
|
902
1085
|
const own = state.activeGroup === id;
|
|
903
1086
|
style = {
|
|
904
1087
|
transform: shift?.transform,
|
|
@@ -914,7 +1097,7 @@ export function useSortableRow({
|
|
|
914
1097
|
? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
|
|
915
1098
|
{ ...style, opacity: 0.4 }
|
|
916
1099
|
: { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
|
|
917
|
-
} else if (type === "item" &&
|
|
1100
|
+
} else if (type === "item" && state.rootDrag && shift) {
|
|
918
1101
|
const own = state.activeGroup !== null && state.activeGroup === groupId;
|
|
919
1102
|
style = {
|
|
920
1103
|
transform: shift.transform,
|
|
@@ -1,18 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ลำดับของรายการ 2 ระดับ (กลุ่ม → รายการในกลุ่ม) แบบ pure — ไม่พึ่ง dnd-kit และไม่พึ่ง React
|
|
3
3
|
* ใช้โดย `@suphark/ui/sortable` และเทสต์ได้ตรง ๆ; แอปเอาไปสร้าง payload ตอนบันทึกได้เลย
|
|
4
|
+
*
|
|
5
|
+
* ชั้นบนสุด (root) มีทั้งกลุ่มและรายการที่ไม่มีกลุ่มปนกันได้ตามใจ — `root` เก็บลำดับนั้น
|
|
6
|
+
* ส่วน `groups` / `items[UNGROUPED]` เป็นมุมมองเดิมที่ได้จาก root (แอปเก่าที่ไม่รู้จัก root ยังอ่านได้)
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
/** รายการที่ยังไม่อยู่กลุ่มไหน — เก็บในถังนี้
|
|
9
|
+
/** รายการที่ยังไม่อยู่กลุ่มไหน — เก็บในถังนี้ */
|
|
7
10
|
export const UNGROUPED = "__ungrouped__";
|
|
8
11
|
|
|
12
|
+
/** สิ่งหนึ่งในชั้นบนสุด: กลุ่ม หรือรายการที่ไม่มีกลุ่ม */
|
|
13
|
+
export interface SortableRootEntry {
|
|
14
|
+
kind: "group" | "item";
|
|
15
|
+
id: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
export interface SortableOrder {
|
|
10
19
|
/** ลำดับของกลุ่ม (ไม่รวม `UNGROUPED`) */
|
|
11
20
|
groups: string[];
|
|
12
21
|
/** id ของรายการในแต่ละกลุ่ม — key เป็น id ของกลุ่มหรือ `UNGROUPED` */
|
|
13
22
|
items: Record<string, string[]>;
|
|
23
|
+
/**
|
|
24
|
+
* ลำดับชั้นบนสุดที่กลุ่มกับรายการไม่มีกลุ่มสลับกันได้ — ไม่มี = รายการไม่มีกลุ่มอยู่บนสุดแล้วตามด้วยกลุ่ม
|
|
25
|
+
* (รูปแบบเดิม) · ใช้ `rootEntries(order)` อ่าน แทนการอ่านฟิลด์นี้ตรง ๆ
|
|
26
|
+
*/
|
|
27
|
+
root?: SortableRootEntry[];
|
|
14
28
|
}
|
|
15
29
|
|
|
30
|
+
/** ลำดับชั้นบนสุด (กลุ่ม + รายการไม่มีกลุ่ม) — แอปใช้วนเรนเดอร์ตามลำดับนี้ */
|
|
31
|
+
export function rootEntries(order: SortableOrder): SortableRootEntry[] {
|
|
32
|
+
if (order.root) return order.root;
|
|
33
|
+
return [
|
|
34
|
+
...(order.items[UNGROUPED] ?? []).map((id) => ({ kind: "item" as const, id })),
|
|
35
|
+
...order.groups.map((id) => ({ kind: "group" as const, id })),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** ตั้งชั้นบนสุดใหม่ แล้วให้ `groups` / `items[UNGROUPED]` ตรงกับมันเสมอ */
|
|
40
|
+
function withRoot(order: SortableOrder, root: SortableRootEntry[]): SortableOrder {
|
|
41
|
+
return {
|
|
42
|
+
...order,
|
|
43
|
+
root,
|
|
44
|
+
groups: root.filter((e) => e.kind === "group").map((e) => e.id),
|
|
45
|
+
items: {
|
|
46
|
+
...order.items,
|
|
47
|
+
[UNGROUPED]: root.filter((e) => e.kind === "item").map((e) => e.id),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sameEntry = (a: SortableRootEntry, b: SortableRootEntry) =>
|
|
53
|
+
a.kind === b.kind && a.id === b.id;
|
|
54
|
+
|
|
16
55
|
/** สร้างลำดับเริ่มต้นจากข้อมูลที่โหลดมา (กลุ่มเรียงตามที่ส่งมา รายการเรียงตามที่ส่งมา) */
|
|
17
56
|
export function sortableOrderOf<TGroup, TItem>(
|
|
18
57
|
groups: ReadonlyArray<TGroup>,
|
|
@@ -23,6 +62,13 @@ export function sortableOrderOf<TGroup, TItem>(
|
|
|
23
62
|
/** รายการนี้อยู่กลุ่มไหน — `null`/`undefined` = ยังไม่อยู่กลุ่มไหน */
|
|
24
63
|
itemGroupId: (item: TItem) => string | null | undefined;
|
|
25
64
|
},
|
|
65
|
+
options?: {
|
|
66
|
+
/**
|
|
67
|
+
* ลำดับชั้นบนสุดที่บันทึกไว้ (จาก `sortableOrderToPayload(order).root`) — ไม่ส่ง = รายการไม่มีกลุ่มก่อน
|
|
68
|
+
* แล้วตามด้วยกลุ่ม · ของที่ไม่มีใน root ต่อท้าย ของใน root ที่ไม่มีอยู่แล้วถูกทิ้ง
|
|
69
|
+
*/
|
|
70
|
+
root?: ReadonlyArray<SortableRootEntry>;
|
|
71
|
+
},
|
|
26
72
|
): SortableOrder {
|
|
27
73
|
const order: SortableOrder = { groups: [], items: { [UNGROUPED]: [] } };
|
|
28
74
|
for (const group of groups) {
|
|
@@ -36,10 +82,14 @@ export function sortableOrderOf<TGroup, TItem>(
|
|
|
36
82
|
if (list) list.push(select.itemId(item));
|
|
37
83
|
else order.items[container] = [select.itemId(item)];
|
|
38
84
|
}
|
|
39
|
-
return order;
|
|
85
|
+
if (!options?.root) return order;
|
|
86
|
+
const known = rootEntries(order);
|
|
87
|
+
const kept = options.root.filter((e) => known.some((k) => sameEntry(k, e)));
|
|
88
|
+
const rest = known.filter((k) => !kept.some((e) => sameEntry(k, e)));
|
|
89
|
+
return withRoot(order, [...kept, ...rest]);
|
|
40
90
|
}
|
|
41
91
|
|
|
42
|
-
/** กลุ่มที่รายการนี้อยู่ (ไม่เจอ = undefined) */
|
|
92
|
+
/** กลุ่มที่รายการนี้อยู่ (`UNGROUPED` = ชั้นบนสุด; ไม่เจอ = undefined) */
|
|
43
93
|
export function groupOfItem(order: SortableOrder, itemId: string): string | undefined {
|
|
44
94
|
return Object.keys(order.items).find((group) => order.items[group]?.includes(itemId));
|
|
45
95
|
}
|
|
@@ -53,19 +103,42 @@ export function moveWithin<T>(list: ReadonlyArray<T>, from: number, to: number):
|
|
|
53
103
|
return next;
|
|
54
104
|
}
|
|
55
105
|
|
|
56
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* ย้ายสิ่งหนึ่งในชั้นบนสุดไปที่ตำแหน่ง `index` (นับในรายการที่ "เอาตัวมันออกแล้ว")
|
|
108
|
+
* ใช้ได้ทั้งกลุ่มและรายการไม่มีกลุ่ม — กลุ่มลงไปอยู่ระหว่างรายการไม่มีกลุ่มได้
|
|
109
|
+
*/
|
|
110
|
+
export function moveRootEntry(
|
|
111
|
+
order: SortableOrder,
|
|
112
|
+
entry: SortableRootEntry,
|
|
113
|
+
index: number,
|
|
114
|
+
): SortableOrder {
|
|
115
|
+
const root = rootEntries(order);
|
|
116
|
+
const from = root.findIndex((e) => sameEntry(e, entry));
|
|
117
|
+
if (from < 0) return order;
|
|
118
|
+
const rest = root.filter((_, i) => i !== from);
|
|
119
|
+
const at = Math.max(0, Math.min(index, rest.length));
|
|
120
|
+
if (at === from) return order;
|
|
121
|
+
return withRoot(order, [
|
|
122
|
+
...rest.slice(0, at),
|
|
123
|
+
root[from] as SortableRootEntry,
|
|
124
|
+
...rest.slice(at),
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** เรียงกลุ่มใหม่: ย้าย `groupId` ไปอยู่ตำแหน่งของ `overGroupId` (ในชั้นบนสุด) */
|
|
57
129
|
export function reorderGroups(
|
|
58
130
|
order: SortableOrder,
|
|
59
131
|
groupId: string,
|
|
60
132
|
overGroupId: string,
|
|
61
133
|
): SortableOrder {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
134
|
+
const root = rootEntries(order);
|
|
135
|
+
const from = root.findIndex((e) => e.kind === "group" && e.id === groupId);
|
|
136
|
+
const to = root.findIndex((e) => e.kind === "group" && e.id === overGroupId);
|
|
64
137
|
if (from < 0 || to < 0 || from === to) return order;
|
|
65
|
-
return
|
|
138
|
+
return withRoot(order, moveWithin(root, from, to));
|
|
66
139
|
}
|
|
67
140
|
|
|
68
|
-
/**
|
|
141
|
+
/** เรียงรายการในกลุ่มเดียวกัน (รวมรายการไม่มีกลุ่มในชั้นบนสุด): ย้าย `itemId` ไปอยู่ตำแหน่งของ `overItemId` */
|
|
69
142
|
export function reorderItems(
|
|
70
143
|
order: SortableOrder,
|
|
71
144
|
itemId: string,
|
|
@@ -73,6 +146,13 @@ export function reorderItems(
|
|
|
73
146
|
): SortableOrder {
|
|
74
147
|
const group = groupOfItem(order, itemId);
|
|
75
148
|
if (!group || group !== groupOfItem(order, overItemId)) return order;
|
|
149
|
+
if (group === UNGROUPED) {
|
|
150
|
+
const root = rootEntries(order);
|
|
151
|
+
const from = root.findIndex((e) => e.kind === "item" && e.id === itemId);
|
|
152
|
+
const to = root.findIndex((e) => e.kind === "item" && e.id === overItemId);
|
|
153
|
+
if (from < 0 || to < 0 || from === to) return order;
|
|
154
|
+
return withRoot(order, moveWithin(root, from, to));
|
|
155
|
+
}
|
|
76
156
|
const list = order.items[group] ?? [];
|
|
77
157
|
const from = list.indexOf(itemId);
|
|
78
158
|
const to = list.indexOf(overItemId);
|
|
@@ -80,8 +160,25 @@ export function reorderItems(
|
|
|
80
160
|
return { ...order, items: { ...order.items, [group]: moveWithin(list, from, to) } };
|
|
81
161
|
}
|
|
82
162
|
|
|
163
|
+
/** เอารายการออกจากที่อยู่ปัจจุบัน (กลุ่ม หรือชั้นบนสุด) */
|
|
164
|
+
function removeItem(order: SortableOrder, itemId: string): SortableOrder {
|
|
165
|
+
const from = groupOfItem(order, itemId);
|
|
166
|
+
if (!from) return order;
|
|
167
|
+
if (from === UNGROUPED) {
|
|
168
|
+
return withRoot(
|
|
169
|
+
order,
|
|
170
|
+
rootEntries(order).filter((e) => !(e.kind === "item" && e.id === itemId)),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
...order,
|
|
175
|
+
items: { ...order.items, [from]: (order.items[from] ?? []).filter((id) => id !== itemId) },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
83
179
|
/**
|
|
84
180
|
* ย้ายรายการไปกลุ่มอื่น — แทรกก่อน `beforeItemId` (ไม่ส่ง = ต่อท้ายกลุ่มปลายทาง)
|
|
181
|
+
* `toGroup = UNGROUPED` = ออกมาอยู่ชั้นบนสุด (ก่อนรายการ `beforeItemId` หรือท้ายสุด) — จะวางก่อนกลุ่มใช้ `moveItemToRoot`
|
|
85
182
|
* เรียกระหว่างลากได้ (ให้แถวไปโผล่ในกลุ่มใหม่ทันที) เพราะคืน object ใหม่เมื่อมีอะไรเปลี่ยนจริงเท่านั้น
|
|
86
183
|
*/
|
|
87
184
|
export function moveItemToGroup(
|
|
@@ -92,27 +189,72 @@ export function moveItemToGroup(
|
|
|
92
189
|
): SortableOrder {
|
|
93
190
|
const from = groupOfItem(order, itemId);
|
|
94
191
|
if (!from || from === toGroup) return order;
|
|
95
|
-
|
|
192
|
+
if (toGroup === UNGROUPED) {
|
|
193
|
+
const root = rootEntries(order);
|
|
194
|
+
const at = beforeItemId
|
|
195
|
+
? root.findIndex((e) => e.kind === "item" && e.id === beforeItemId)
|
|
196
|
+
: -1;
|
|
197
|
+
return moveItemToRoot(order, itemId, at >= 0 ? (root[at] as SortableRootEntry) : null);
|
|
198
|
+
}
|
|
199
|
+
const removed = removeItem(order, itemId);
|
|
200
|
+
const target = removed.items[toGroup] ?? [];
|
|
96
201
|
const at = beforeItemId ? target.indexOf(beforeItemId) : -1;
|
|
97
202
|
const index = at >= 0 ? at : target.length;
|
|
98
203
|
return {
|
|
99
|
-
...
|
|
204
|
+
...removed,
|
|
100
205
|
items: {
|
|
101
|
-
...
|
|
102
|
-
[from]: (order.items[from] ?? []).filter((id) => id !== itemId),
|
|
206
|
+
...removed.items,
|
|
103
207
|
[toGroup]: [...target.slice(0, index), itemId, ...target.slice(index)],
|
|
104
208
|
},
|
|
105
209
|
};
|
|
106
210
|
}
|
|
107
211
|
|
|
108
|
-
/**
|
|
212
|
+
/** ย้ายรายการออกมาอยู่ชั้นบนสุด ก่อน `before` (กลุ่มหรือรายการ; `null` = ท้ายสุด) */
|
|
213
|
+
export function moveItemToRoot(
|
|
214
|
+
order: SortableOrder,
|
|
215
|
+
itemId: string,
|
|
216
|
+
before: SortableRootEntry | null,
|
|
217
|
+
): SortableOrder {
|
|
218
|
+
const from = groupOfItem(order, itemId);
|
|
219
|
+
if (!from) return order;
|
|
220
|
+
const removed = removeItem(order, itemId);
|
|
221
|
+
const root = rootEntries(removed);
|
|
222
|
+
const at = before ? root.findIndex((e) => sameEntry(e, before)) : -1;
|
|
223
|
+
const index = at >= 0 ? at : root.length;
|
|
224
|
+
const entry: SortableRootEntry = { kind: "item", id: itemId };
|
|
225
|
+
const next = [...root.slice(0, index), entry, ...root.slice(index)];
|
|
226
|
+
if (from === UNGROUPED && sameRoot(next, rootEntries(order))) return order;
|
|
227
|
+
return withRoot(removed, next);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sameRoot(a: ReadonlyArray<SortableRootEntry>, b: ReadonlyArray<SortableRootEntry>) {
|
|
231
|
+
return a.length === b.length && a.every((e, i) => sameEntry(e, b[i] as SortableRootEntry));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** ลำดับเหมือนกันไหม (ใช้ตัดสินว่าต้องบันทึกหรือไม่) */
|
|
235
|
+
export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
|
|
236
|
+
if (a === b) return true;
|
|
237
|
+
if (!sameRoot(rootEntries(a), rootEntries(b))) return false;
|
|
238
|
+
const keys = new Set([...Object.keys(a.items), ...Object.keys(b.items)]);
|
|
239
|
+
for (const key of keys) {
|
|
240
|
+
const left = a.items[key] ?? [];
|
|
241
|
+
const right = b.items[key] ?? [];
|
|
242
|
+
if (left.length !== right.length) return false;
|
|
243
|
+
if (left.some((id, i) => right[i] !== id)) return false;
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** รายการทั้งหมดเรียงตามที่แสดง: ไล่ชั้นบนสุด กลุ่มคลี่รายการของมันออกมา */
|
|
109
249
|
export function flatItems(order: SortableOrder): string[] {
|
|
110
|
-
return
|
|
250
|
+
return rootEntries(order).flatMap((e) =>
|
|
251
|
+
e.kind === "item" ? [e.id] : (order.items[e.id] ?? []),
|
|
252
|
+
);
|
|
111
253
|
}
|
|
112
254
|
|
|
113
255
|
/**
|
|
114
256
|
* ลากหลายรายการพร้อมกัน (bulk): ดึงรายการใน `ids` ทั้งหมดมารวมที่ตำแหน่งของ `activeId`
|
|
115
|
-
* (ตัวที่ผู้ใช้จับลาก) เรียงตามลำดับใน `ids` — ตัวอื่นหายจากที่เดิม
|
|
257
|
+
* (ตัวที่ผู้ใช้จับลาก) เรียงตามลำดับใน `ids` — ตัวอื่นหายจากที่เดิม ไม่ว่าจะอยู่กลุ่มไหนหรือชั้นบนสุด
|
|
116
258
|
*/
|
|
117
259
|
export function gatherItems(
|
|
118
260
|
order: SortableOrder,
|
|
@@ -123,26 +265,15 @@ export function gatherItems(
|
|
|
123
265
|
const moving = ids.filter((id) => present.has(id));
|
|
124
266
|
if (moving.length <= 1 || !moving.includes(activeId)) return order;
|
|
125
267
|
const others = new Set(moving.filter((id) => id !== activeId));
|
|
268
|
+
const expand = (id: string) => (others.has(id) ? [] : id === activeId ? moving : [id]);
|
|
126
269
|
const items: Record<string, string[]> = {};
|
|
127
270
|
for (const [group, list] of Object.entries(order.items)) {
|
|
128
|
-
items[group] = list.flatMap(
|
|
129
|
-
}
|
|
130
|
-
return { ...order, items };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** ลำดับเหมือนกันไหม (ใช้ตัดสินว่าต้องบันทึกหรือไม่) */
|
|
134
|
-
export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
|
|
135
|
-
if (a === b) return true;
|
|
136
|
-
if (a.groups.length !== b.groups.length) return false;
|
|
137
|
-
if (a.groups.some((id, i) => b.groups[i] !== id)) return false;
|
|
138
|
-
const keys = new Set([...Object.keys(a.items), ...Object.keys(b.items)]);
|
|
139
|
-
for (const key of keys) {
|
|
140
|
-
const left = a.items[key] ?? [];
|
|
141
|
-
const right = b.items[key] ?? [];
|
|
142
|
-
if (left.length !== right.length) return false;
|
|
143
|
-
if (left.some((id, i) => right[i] !== id)) return false;
|
|
271
|
+
if (group !== UNGROUPED) items[group] = list.flatMap(expand);
|
|
144
272
|
}
|
|
145
|
-
|
|
273
|
+
const root = rootEntries(order).flatMap((e) =>
|
|
274
|
+
e.kind === "group" ? [e] : expand(e.id).map((id) => ({ kind: "item" as const, id })),
|
|
275
|
+
);
|
|
276
|
+
return withRoot({ ...order, items: { ...order.items, ...items } }, root);
|
|
146
277
|
}
|
|
147
278
|
|
|
148
279
|
export interface SortableOrderPayload {
|
|
@@ -150,10 +281,15 @@ export interface SortableOrderPayload {
|
|
|
150
281
|
groups: string[];
|
|
151
282
|
/** รายการในแต่ละกลุ่ม เรียงตามลำดับกลุ่ม โดยของที่ไม่มีกลุ่มมาก่อนด้วย `groupId: null` */
|
|
152
283
|
items: Array<{ groupId: string | null; itemIds: string[] }>;
|
|
284
|
+
/**
|
|
285
|
+
* ลำดับชั้นบนสุด (กลุ่มกับรายการไม่มีกลุ่มปนกัน) — เก็บเป็นตำแหน่งเดียวกันของทั้งสองชนิด
|
|
286
|
+
* แล้วส่งกลับเข้า `sortableOrderOf(..., { root })` ตอนโหลด
|
|
287
|
+
*/
|
|
288
|
+
root: SortableRootEntry[];
|
|
153
289
|
}
|
|
154
290
|
|
|
155
291
|
/**
|
|
156
|
-
* แปลงเป็นรูปที่ส่งเข้า server action ได้ —
|
|
292
|
+
* แปลงเป็นรูปที่ส่งเข้า server action ได้ — คืนทั้งลำดับกลุ่ม รายการในกลุ่ม และชั้นบนสุด
|
|
157
293
|
* ชื่อฟิลด์เป็นศัพท์กลางของแพ็กเกจ (`groupId`) แอปที่ใช้ศัพท์อื่น (เช่น `sectionId`) rename ชั้นเดียวที่ปลายทาง
|
|
158
294
|
*/
|
|
159
295
|
export function sortableOrderToPayload(order: SortableOrder): SortableOrderPayload {
|
|
@@ -163,5 +299,6 @@ export function sortableOrderToPayload(order: SortableOrder): SortableOrderPaylo
|
|
|
163
299
|
groupId: group === UNGROUPED ? null : group,
|
|
164
300
|
itemIds: order.items[group] ?? [],
|
|
165
301
|
})),
|
|
302
|
+
root: rootEntries(order).map((e) => ({ ...e })),
|
|
166
303
|
};
|
|
167
304
|
}
|
package/src/sortable.ts
CHANGED
|
@@ -22,11 +22,15 @@ export {
|
|
|
22
22
|
gatherItems,
|
|
23
23
|
groupOfItem,
|
|
24
24
|
moveItemToGroup,
|
|
25
|
+
moveItemToRoot,
|
|
26
|
+
moveRootEntry,
|
|
25
27
|
moveWithin,
|
|
26
28
|
reorderGroups,
|
|
27
29
|
reorderItems,
|
|
30
|
+
rootEntries,
|
|
28
31
|
type SortableOrder,
|
|
29
32
|
type SortableOrderPayload,
|
|
33
|
+
type SortableRootEntry,
|
|
30
34
|
sameOrder,
|
|
31
35
|
sortableOrderOf,
|
|
32
36
|
sortableOrderToPayload,
|