Học từ Facebook #35: Kéo-thả sắp xếp và vùng thả file không nhấp nháy
Hai mẹo kéo-thả của Facebook: chỉ đổi chỗ khi mép phần tử vượt mép đích, và đếm dragenter/dragleave để overlay thả file không chớp. Kèm hai bug bản gốc.
Kéo-thả bằng HTML5 Drag and Drop API trông đơn giản: draggable, vài sự kiện dragstart, dragover, drop. Làm thử thì gặp ngay hai kiểu "nhấp nháy" rất khó chịu:
- Danh sách sắp xếp lại giật qua giật lại. Kéo một ảnh nhỏ qua một ảnh to: vừa chạm là đổi chỗ, đổi xong thì con trỏ lại nằm trên phần tử khác, đổi tiếp… danh sách rung như lên cơn.
- Overlay "Thả file vào đây" chớp tắt liên tục khi rê file qua các nút, ô chữ bên trong vùng thả.
Trình đăng ảnh của Facebook (thư viện ảnh khi tạo bài) có lời giải gọn cho cả hai, trong hai hook useCometDraggableItems và useFileDragEvents. Đọc kỹ thì mỗi hook còn một bug nhỏ.
Tip
Tóm tắt
- Đổi chỗ chỉ khi mép dẫn của phần tử đang kéo (con trỏ cộng khoảng cách tới mép, nhớ lúc
dragstart) vượt mép của phần tử đích, theo đúng hướng đang kéo. - Overlay thả file: dùng bộ đếm
dragenter/dragleavethay cho cờ boolean, vìdragleavebắn mỗi khi con trỏ đi qua một phần tử con. - Bug bản gốc 1: thứ tự mới được lưu ở
dragend, nên bấm Esc huỷ kéo vẫn đổi thứ tự. - Bug bản gốc 2: nhận diện "đang kéo file" bằng
dataTransfer.files != null, nhưngfileskhông bao giờ null, nên kéo một dòng chữ cũng bật overlay. DùngdataTransfer.types.includes('Files').
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
Thử kéo "Video ngắn" xuống qua "Album 12 ảnh" ở hai chế độ. Trong test tự động của mình, cùng một đường kéo cho 2 lần đổi chỗ với cách của Facebook và 14 lần với cách "đổi ngay khi chạm". Ở ô bên phải, cùng một lượt rê file qua 4 phần tử con làm overlay bật/tắt 2 lần với bộ đếm, 12 lần với cờ boolean.
#Facebook làm thế nào?
#Ngưỡng theo mép phần tử (useCometDraggableItems)
Module nằm trong 0 00MediaLibrary/media-upload/useCometDraggableItems.js (có thêm một bản gần giống trong DraggableList/). Lúc bắt đầu kéo, hook đo khoảng cách từ con trỏ tới bốn mép của phần tử:
onDragStart(event) {
const target = event.currentTarget;
event.dataTransfer?.setData('text/plain', target?.textContent ?? window.location.href);
lastPointer.current = { x: event.pageX, y: event.pageY };
const rect = target.getBoundingClientRect();
edges.current = {
top: event.clientY - rect.top,
bottom: rect.bottom - event.clientY,
left: event.clientX - rect.left,
right: rect.right - event.clientX,
};
setDragIndex(Number(target.dataset.key));
}
js
Khi con trỏ đi qua phần tử khác, chỉ đổi chỗ nếu mép đi đầu của phần tử đang kéo đã vượt mép tương ứng của phần tử đích:
onDragOver(event) {
event.preventDefault();
event.dataTransfer && (event.dataTransfer.dropEffect = 'move');
const index = Number(event.currentTarget.dataset.key);
if (isNaN(index) || index === targetIndex) return;
const rect = event.currentTarget.getBoundingClientRect();
const delta = direction === 'vertical' ? event.pageY - lastPointer.current.y : event.pageX - lastPointer.current.x;
const { top, bottom, left, right } = edges.current;
if (direction === 'vertical') {
if (delta < 0) event.clientY - top < rect.top && setTargetIndex(index); // kéo lên: mép trên vượt mép trên
else if (delta > 0) event.clientY + bottom > rect.bottom && setTargetIndex(index); // kéo xuống: mép dưới vượt mép dưới
} else { /* tương tự với left/right */ }
lastPointer.current = { x: event.pageX, y: event.pageY };
}
js
Hướng kéo lấy từ chênh lệch toạ độ giữa hai lần dragover. Không dùng điểm giữa, không dùng "con trỏ đã vào phần tử". Hệ quả: một ảnh cao 44px kéo qua một album cao 96px chỉ đổi chỗ khi cả ảnh đã trượt qua mép dưới của album. Lúc đó con trỏ đã nằm chắc trên vị trí mới, không có lần đổi ngược lại.
Danh sách hiển thị trong lúc kéo là một bản xem trước (splice ra khỏi vị trí cũ, chèn vào vị trí mới). Danh sách thật chỉ được cập nhật trong onDragEnd: nếu vị trí đích khác vị trí ban đầu thì splice trên mảng gốc rồi gọi onReorder(next, from, to), sau đó đặt cả hai chỉ số về -1.
Bug: dragend bắn cho mọi cách kết thúc kéo: thả vào đúng chỗ, thả ra ngoài danh sách, hoặc bấm Esc. Code không phân biệt, nên huỷ kéo bằng Esc vẫn lưu thứ tự mới. Cách sửa: đánh dấu khi drop thật sự xảy ra, chỉ commit nếu có cờ đó (hoặc kiểm tra event.dataTransfer.dropEffect === 'none' trong dragend). Demo có công tắc "Commit cả khi huỷ (bản gốc)": cùng một lần kéo bị huỷ cho thứ tự bcdaef với bản gốc và giữ nguyên abcdef với bản sửa.
#Bộ đếm cho overlay thả file (useFileDragEvents)
Module trong Drop UPload/useFileDragEvents.js:
function useFileDragEvents(onDrop, target = document.documentElement, filter) {
const depth = useRef(0);
const [isDragging, setIsDragging] = useState(false);
const handleEnter = (e) => {
const files = e.dataTransfer?.files;
if ((filter == null || filter(e)) && files != null) { e.preventDefault(); depth.current++; setIsDragging(true); }
};
const handleLeave = (e) => {
const files = e.dataTransfer?.files;
if (files != null && --depth.current === 0) setIsDragging(false);
};
const handleOver = (e) => { if (e.dataTransfer?.files != null) e.preventDefault(); };
const handleDrop = (e) => { setIsDragging(false); /* gọi onDrop(files), depth.current = 0 */ };
useEffect(() => {
target.addEventListener('drop', handleDrop, true);
target.addEventListener('dragenter', handleEnter, true);
target.addEventListener('dragleave', handleLeave, true);
target.addEventListener('dragover', handleOver, true);
return () => { /* gỡ cả 4 */ };
}, [...]);
return isDragging;
}
js
Ba chi tiết đáng học: listener gắn ở capture phase trên cả trang (document.documentElement), preventDefault() trong dragover (thiếu dòng này thì trình duyệt không cho thả), và bộ đếm depth.
Bug: điều kiện files != null được đặt ra để chỉ phản ứng khi kéo file. Nhưng theo spec, DataTransfer.files luôn là một FileList, rỗng khi không kéo file, không bao giờ null. Kéo một dòng chữ, một link hay một ảnh có sẵn trong trang cũng làm bộ đếm tăng và bật overlay "Thả file vào đây". Trong demo, kéo "dòng chữ này" vào khung: bản gốc bật/tắt overlay 2 lần, bản sửa 0 lần. Ngoài ra trong lúc dragenter/dragover, trình duyệt giấu nội dung file (chỉ cho biết loại) để trang không đọc trộm được file người dùng mới kéo ngang qua. Cách đúng để biết đang kéo file là dataTransfer.types.includes('Files').
sequenceDiagram participant U as Con trỏ participant Z as Vùng thả participant C as Ô con U->>Z: dragenter (depth = 1, hiện overlay) U->>C: dragenter (depth = 2) U->>Z: dragleave (depth = 1, vẫn hiện) Note over Z,C: Cờ boolean sẽ tắt overlay ở bước này: nhấp nháy U->>Z: dragenter (depth = 2) U->>C: dragleave (depth = 1) U->>Z: drop (depth = 0, ẩn, xử lý file)
#Bên trong trình duyệt: vì sao dragleave "nói dối"?
dragenter và dragleave hoạt động như mouseover/mouseout, không như mouseenter/mouseleave. Chúng bắn cho từng phần tử con trỏ đi vào/ra, và nổi bọt (bubble). Khi con trỏ đi từ vùng thả vào một nút bên trong:
dragenterbắn trên nút (nổi lên vùng thả).dragleavebắn trên vùng thả, vì con trỏ vừa "rời" vùng thả để vào nút.
Thứ tự là enter trước, leave sau. Nếu dùng cờ boolean, bước 2 tắt overlay trong khi con trỏ vẫn đang ở trong vùng. Bộ đếm cân bằng mọi cặp enter/leave, chỉ về 0 khi con trỏ thật sự ra khỏi cả vùng.
Không có dragenter/dragleave phiên bản "không nổi bọt" như mouseenter. Hai cách khác thường gặp:
- So
event.relatedTargettrongdragleave: bỏ qua nếu nó vẫn nằm trong vùng thả. Cách này phụ thuộc việc trình duyệt điền đúngrelatedTargetcho sự kiện kéo; bộ đếm thì không cần chi tiết đó. - Đặt
pointer-events: nonecho mọi phần tử con khi đang kéo. Hiệu quả, nhưng phải thêm class vào cả cây.
#Viết lại
Hook sắp xếp giữ nguyên ngưỡng theo mép, thêm cờ dropped để không commit khi huỷ:
src/lib/dragReorder.ts
onDragOver: (event: DragEvent<HTMLElement>) => {
if (from < 0) return; // không phải phần tử của danh sách này
event.preventDefault(); // cho phép thả
event.dataTransfer.dropEffect = 'move';
if (index === to) return;
const rect = event.currentTarget.getBoundingClientRect();
const vertical = direction === 'vertical';
const delta = vertical ? event.clientY - lastPointer.current.y : event.clientX - lastPointer.current.x;
lastPointer.current = { x: event.clientX, y: event.clientY };
if (swapRule === 'enter') {
setTo(index);
return;
}
const e = edges.current;
if (vertical) {
if (delta < 0 && event.clientY - e.top < rect.top) setTo(index);
else if (delta > 0 && event.clientY + e.bottom > rect.bottom) setTo(index);
} else {
if (delta < 0 && event.clientX - e.left < rect.left) setTo(index);
else if (delta > 0 && event.clientX + e.right > rect.right) setTo(index);
}
},
onDrop: (event: DragEvent<HTMLElement>) => {
if (from < 0) return;
event.preventDefault();
dropped.current = true;
},
onDragEnd: finish,
});
ts
onDragEnd gọi finish(): chỉ khi dropped.current là true (hoặc bật chế độ giống bản gốc) mới gọi onReorder với danh sách xem trước.
Hook vùng thả file dùng bộ đếm và nhận diện file qua types:
src/lib/dragReorder.ts
if (!el) return;
const isFileDrag = (event: DragEvent | globalThis.DragEvent) => {
const dt = event.dataTransfer;
if (!dt) return false;
return detect === 'types' ? Array.from(dt.types).includes('Files') : dt.files != null;
};
const show = (value: boolean) => {
if (visible.current === value) return;
visible.current = value;
setActive(value);
latest.current.onToggle?.(value);
};
const onEnter = (event: globalThis.DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depth.current++;
show(true);
};
const onLeave = (event: globalThis.DragEvent) => {
if (!isFileDrag(event)) return;
if (mode === 'boolean') show(false);
else if (--depth.current <= 0) {
depth.current = 0;
show(false);
}
};
ts
Xem toàn bộ src/lib/dragReorder.ts
src/lib/dragReorder.ts
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from 'react';
/**
* Sắp xếp lại danh sách bằng HTML5 drag-and-drop, không nhấp nháy
* (ý tưởng từ useCometDraggableItems của Facebook).
*
* Mẹo chính: lúc bắt đầu kéo, nhớ khoảng cách từ con trỏ tới 4 mép của phần tử đang kéo.
* Khi kéo lên, chỉ đổi chỗ khi MÉP TRÊN của phần tử đang kéo (con trỏ trừ khoảng cách đó)
* đã vượt mép trên của phần tử đích; kéo xuống thì xét mép dưới. Nhờ vậy không đổi chỗ
* qua lại liên tục khi hai phần tử cao khác nhau.
*
* Khác bản gốc: chỉ commit khi thật sự thả (drop). Bản gốc commit ở dragend, nên bấm Esc
* hoặc thả ra ngoài vẫn đổi thứ tự.
*/
export type DragDirection = 'vertical' | 'horizontal';
export interface DragReorderOptions<T> {
items: T[];
getKey: (item: T) => string;
onReorder: (next: T[], from: number, to: number) => void;
direction?: DragDirection;
/** 'edge' = cách của Facebook, 'enter' = đổi ngay khi con trỏ chạm phần tử khác */
swapRule?: 'edge' | 'enter';
/** true = commit cả khi huỷ (Esc, thả ra ngoài), giống bản gốc */
commitOnCancel?: boolean;
/** được gọi mỗi lần thứ tự xem trước thay đổi (để đếm "nhấp nháy") */
onPreviewChange?: () => void;
}
interface Edges {
top: number;
bottom: number;
left: number;
right: number;
}
export function useDragReorder<T>({
items,
getKey,
onReorder,
direction = 'vertical',
swapRule = 'edge',
commitOnCancel = false,
onPreviewChange,
}: DragReorderOptions<T>) {
const [from, setFrom] = useState(-1);
const [to, setTo] = useState(-1);
const edges = useRef<Edges>({ top: 0, bottom: 0, left: 0, right: 0 });
const lastPointer = useRef({ x: 0, y: 0 });
const dropped = useRef(false);
// danh sách xem trước: phần tử đang kéo đã nằm ở vị trí `to`
const preview = useMemo(() => {
const next = items.slice();
if (from >= 0 && to >= 0 && from !== to) next.splice(to, 0, next.splice(from, 1)[0]);
return next;
}, [items, from, to]);
const onPreviewChangeRef = useRef(onPreviewChange);
onPreviewChangeRef.current = onPreviewChange;
useEffect(() => {
if (from >= 0) onPreviewChangeRef.current?.();
}, [to, from]);
const finish = useCallback(() => {
if ((dropped.current || commitOnCancel) && from >= 0 && to >= 0 && from !== to) {
onReorder(preview, from, to);
}
dropped.current = false;
setFrom(-1);
setTo(-1);
}, [commitOnCancel, from, to, preview, onReorder]);
const getItemProps = (index: number) => ({
draggable: true,
'data-index': index,
onDragStart: (event: DragEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
edges.current = {
top: event.clientY - rect.top,
bottom: rect.bottom - event.clientY,
left: event.clientX - rect.left,
right: rect.right - event.clientX,
};
lastPointer.current = { x: event.clientX, y: event.clientY };
event.dataTransfer.effectAllowed = 'move';
// Firefox chỉ bắt đầu kéo khi có dữ liệu
event.dataTransfer.setData('text/plain', getKey(items[index]));
dropped.current = false;
setFrom(index);
setTo(index);
},
onDragOver: (event: DragEvent<HTMLElement>) => {
if (from < 0) return; // không phải phần tử của danh sách này
event.preventDefault(); // cho phép thả
event.dataTransfer.dropEffect = 'move';
if (index === to) return;
const rect = event.currentTarget.getBoundingClientRect();
const vertical = direction === 'vertical';
const delta = vertical ? event.clientY - lastPointer.current.y : event.clientX - lastPointer.current.x;
lastPointer.current = { x: event.clientX, y: event.clientY };
if (swapRule === 'enter') {
setTo(index);
return;
}
const e = edges.current;
if (vertical) {
if (delta < 0 && event.clientY - e.top < rect.top) setTo(index);
else if (delta > 0 && event.clientY + e.bottom > rect.bottom) setTo(index);
} else {
if (delta < 0 && event.clientX - e.left < rect.left) setTo(index);
else if (delta > 0 && event.clientX + e.right > rect.right) setTo(index);
}
},
onDrop: (event: DragEvent<HTMLElement>) => {
if (from < 0) return;
event.preventDefault();
dropped.current = true;
},
onDragEnd: finish,
});
return { preview, draggingIndex: to, isDragging: from >= 0, getItemProps };
}
/**
* Overlay "thả file vào đây" không nhấp nháy (ý tưởng từ useFileDragEvents).
*
* dragleave bắn mỗi khi con trỏ đi qua MỘT PHẦN TỬ CON, nên dùng bộ đếm enter/leave
* thay vì một cờ boolean. Khác bản gốc:
* - nhận diện kéo file bằng dataTransfer.types.includes('Files'). Bản gốc kiểm
* `dataTransfer.files != null`, mà `files` luôn là một FileList (có thể rỗng),
* nên kéo một dòng chữ hay một ảnh trong trang cũng hiện overlay;
* - reset bộ đếm cả khi thả ở nơi khác hoặc khi kéo kết thúc ngoài cửa sổ.
*/
export interface FileDropOptions {
onFiles: (files: File[]) => void;
/** 'counter' (mặc định) hoặc 'boolean' (cách ngây thơ, để so sánh) */
mode?: 'counter' | 'boolean';
/** 'types' (mặc định) hoặc 'files-not-null' như bản gốc */
detect?: 'types' | 'files-not-null';
onToggle?: (visible: boolean) => void;
}
export function useFileDropZone<E extends HTMLElement>({ onFiles, mode = 'counter', detect = 'types', onToggle }: FileDropOptions) {
const ref = useRef<E>(null);
const [active, setActive] = useState(false);
const depth = useRef(0);
const visible = useRef(false);
const latest = useRef({ onFiles, onToggle });
latest.current = { onFiles, onToggle };
useEffect(() => {
const el = ref.current;
if (!el) return;
const isFileDrag = (event: DragEvent | globalThis.DragEvent) => {
const dt = event.dataTransfer;
if (!dt) return false;
return detect === 'types' ? Array.from(dt.types).includes('Files') : dt.files != null;
};
const show = (value: boolean) => {
if (visible.current === value) return;
visible.current = value;
setActive(value);
latest.current.onToggle?.(value);
};
const onEnter = (event: globalThis.DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depth.current++;
show(true);
};
const onLeave = (event: globalThis.DragEvent) => {
if (!isFileDrag(event)) return;
if (mode === 'boolean') show(false);
else if (--depth.current <= 0) {
depth.current = 0;
show(false);
}
};
const onOver = (event: globalThis.DragEvent) => {
if (isFileDrag(event)) event.preventDefault();
};
const onDrop = (event: globalThis.DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depth.current = 0;
show(false);
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length) latest.current.onFiles(files);
};
// kéo kết thúc ở chỗ khác (thả ngoài vùng, Esc): đưa bộ đếm về 0
const reset = () => {
depth.current = 0;
show(false);
};
el.addEventListener('dragenter', onEnter);
el.addEventListener('dragleave', onLeave);
el.addEventListener('dragover', onOver);
el.addEventListener('drop', onDrop);
window.addEventListener('drop', reset);
window.addEventListener('dragend', reset);
return () => {
el.removeEventListener('dragenter', onEnter);
el.removeEventListener('dragleave', onLeave);
el.removeEventListener('dragover', onOver);
el.removeEventListener('drop', onDrop);
window.removeEventListener('drop', reset);
window.removeEventListener('dragend', reset);
};
}, [mode, detect]);
return { ref, active };
}
ts
#Đi qua code
setData('text/plain', …)trongdragstart. Firefox không bắt đầu kéo nếuDataTransferrỗng. Facebook cũng làm đúng điều này.if (from < 0) returntrongdragover. Chỉ phản ứng với phần tử của chính danh sách này. Kéo file từ máy vào danh sách thì bỏ qua.- Bản xem trước bằng
useMemo. Phần tử đang kéo đã nằm ở vị trí mới trongpreview, nêndata-indexcủa từng phần tử trong lúc kéo là vị trí sau khi đổi. Đó là lý dotovừa là vị trí đích vừa là chỉ số của phần tử đang kéo trong bản xem trước. keyổn định theogetKey(item), để React không tháo/lắp lại phần tử khi đổi chỗ (mất trạng thái:hover, ảnh nháy lại).show()chỉ gọisetActivekhi giá trị đổi và gọionTogglengoài hàm updater củasetState(updater có thể chạy hai lần trong StrictMode).windownghedropvàdragendđể reset. Trường hợp kéo bắt đầu trong trang (kéo ảnh của chính trang ra rồi vào) và kết thúc ở nơi khác, bộ đếm có thể lệch; reset ở đây giữ nó về 0.
Khác bản gốc: commit chỉ khi drop; nhận diện file bằng types; reset bộ đếm khi kéo kết thúc ở nơi khác; listener gắn trên phần tử thay vì cả trang (bản gốc phủ cả trang để thả file vào đâu cũng được: tuỳ thiết kế).
#Tại sao không làm cách khác?
Sao không dùng điểm giữa phần tử làm ngưỡng? Ngưỡng điểm giữa chạy ổn khi các phần tử cao bằng nhau. Khi một phần tử nhỏ đổi chỗ với phần tử to, sau khi đổi, điểm giữa của phần tử vừa bị đẩy đi có thể lại nằm dưới con trỏ, gây đổi ngược. Ngưỡng theo mép phụ thuộc kích thước phần tử đang kéo, nên ổn định với phần tử khác cỡ.
Sao không dùng Pointer Events thay cho HTML5 DnD? HTML5 DnD có sẵn ảnh "bóng ma" khi kéo, kéo được sang cửa sổ/ứng dụng khác, nhưng không chạy trên màn hình cảm ứng ở nhiều trình duyệt di động. Thư viện như dnd-kit dùng Pointer Events để chạy được ở mọi nơi và hỗ trợ bàn phím. Với chức năng thả file từ máy, HTML5 DnD là cách duy nhất.
Sao không commit ngay mỗi lần đổi chỗ? Gọi API lưu thứ tự trong lúc đang kéo sinh ra hàng chục request vô ích và không huỷ được.
#Edge case và cạm bẫy
- Kéo nhanh.
dragoverbắn lặp lại theo chu kỳ (cả khi con trỏ đứng yên), không phải mỗi lần chuột nhích. Kéo rất nhanh có thể "nhảy" qua một phần tử; vì so sánh mép theo toạ độ thật nên lầndragovertiếp theo trên phần tử mới vẫn bắt kịp. - Danh sách cuộn được. Trình duyệt tự cuộn khi kéo gần mép phần tử cuộn được, nhưng mức hỗ trợ khác nhau. Danh sách dài nên tự viết auto-scroll (xem bài kéo-thả lịch của Pipedrive).
- Khả năng truy cập. Kéo-thả không dùng được bằng bàn phím. Thêm nút "Lên/Xuống" hoặc phím tắt Alt+↑/↓ cho mỗi phần tử.
- Thả file ra ngoài vùng. Trình duyệt mặc định mở file đó, thay trang hiện tại. Nếu cả trang có thể nhận file, hãy
preventDefault()chodragover/dropở cấpwindownhư Facebook (gắn trêndocumentElement). - Kéo thư mục.
dataTransfer.fileschỉ chứa mục cấp đầu; muốn duyệt thư mục phải dùngdataTransfer.items[i].webkitGetAsEntry().
#Dùng trong dự án
function PhotoOrder({ photos, onSave }: { photos: Photo[]; onSave: (p: Photo[]) => void }) {
const { preview, getItemProps, draggingIndex, isDragging } = useDragReorder({
items: photos,
getKey: (p) => p.id,
direction: 'horizontal',
onReorder: (next) => onSave(next),
});
const drop = useFileDropZone<HTMLDivElement>({ onFiles: (files) => upload(files) });
return (
<div ref={drop.ref} className={drop.active ? 'drop-active' : ''}>
{preview.map((photo, i) => (
<img key={photo.id} src={photo.thumb} {...getItemProps(i)} className={isDragging && i === draggingIndex ? 'ghost' : ''} />
))}
</div>
);
}
tsx
#Kiểm thử
Kéo-thả khó unit test vì phụ thuộc toạ độ thật. Cách đơn giản là bắn DragEvent tổng hợp với một DataTransfer thật trong trình duyệt (Playwright), đúng như cách demo được kiểm tra.
Ví dụ test (bản phác, không có trong repo)
import { expect, test } from '@playwright/test';
test('rê file qua phần tử con: overlay chỉ bật/tắt một lần', async ({ page }) => {
await page.goto('/#/drag-drop');
const toggles = await page.evaluate(async () => {
const zone = document.querySelector('[data-testid=zone]')!;
const children = zone.querySelectorAll('.dd-chip');
const dt = new DataTransfer();
dt.items.add(new File(['x'], 'anh.jpg', { type: 'image/jpeg' }));
const fire = (el: Element, type: string) => el.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }));
fire(zone, 'dragenter');
let prev: Element = zone;
for (const c of children) {
fire(c, 'dragenter'); // enter con trước
fire(prev, 'dragleave'); // rồi mới leave cha
prev = c;
}
fire(zone, 'drop');
return document.querySelector('[data-testid=toggles]')!.textContent;
});
expect(toggles).toBe('2'); // bật 1 lần, tắt 1 lần khi thả
});
test('kéo chữ không bật overlay', async ({ page }) => {
await page.goto('/#/drag-drop');
await page.evaluate(() => {
const zone = document.querySelector('[data-testid=zone]')!;
const dt = new DataTransfer();
dt.setData('text/plain', 'xin chào');
zone.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }));
});
await expect(page.getByTestId('toggles')).toHaveText('0');
});
ts
#So với thư viện
- dnd-kit dùng Pointer Events, có "collision detection" cấu hình được (closestCenter, rectIntersection…), hỗ trợ bàn phím và cảm ứng.
- react-dropzone xử lý vùng thả file, cũng dùng bộ đếm
dragenter/dragleavebên trong. - SortableJS có tuỳ chọn
swapThresholdvàinvertSwap, cùng ý tưởng "chỉ đổi chỗ khi vượt ngưỡng" như Facebook.
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy (bài này)
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

