Задача: перетягнути елемент мишкою у власному OWL-компоненті — картку між колонками
дошки, рядок у списку, маркер на карті. Перший інстинкт — узяти нативний HTML5 Drag and
Drop API: draggable="true", dragstart/dragover/drop, dataTransfer. Він працює
в будь-якому браузері і не потребує бібліотек. Ось так це зазвичай і пишуть:
Неправильно. Ручні нативні обробники:
class TaskBoard extends Component {
static template = "my_module.TaskBoard";
setup() {
this.orm = useService("orm");
}
onDragStart(ev, taskId) {
ev.dataTransfer.setData("text/plain", taskId);
}
onDragOver(ev) {
ev.preventDefault(); // без цього drop не спрацює
}
async onDrop(ev, stageId) {
const taskId = parseInt(ev.dataTransfer.getData("text/plain"));
await this.orm.write("project.task", [taskId], { stage_id: stageId });
}
}
<div t-foreach="tasks" t-as="task" t-key="task.id"
draggable="true" t-on-dragstart="(ev) => this.onDragStart(ev, task.id)">
<t t-esc="task.name"/>
</div>
<div t-on-dragover="onDragOver" t-on-drop="(ev) => this.onDrop(ev, stage.id)"/>
Працює. Але dataTransfer — це нативне HTML5 Drag and Drop API, а воно взагалі не
породжує події dragstart/dragover/drop на тач-екранах — там немає такого поняття
драгу мишкою. Плейсхолдер під час перетягування, скрол краю екрана при підведенні
курсора до межі, обмеження «перетягувати можна тільки за цей елемент» — усе це довелось
би дописувати руками.
У ядрі Odoo є готове рішення, і воно там не випадково: useDraggable
(@web/core/utils/draggable.js) і useSortable (@web/core/utils/sortable_owl.js).
Обидва хуки — надбудова над одним білдером makeDraggableHook
(draggable_hook_builder.js), а Owl-версія (draggable_hook_builder_owl.js) підключає
до нього useEffect, useExternalListener і reactive — тобто хук сам стежить за
mount/unmount компонента, ви нічого вручну не відписуєте.
Важливо! Ці хуки НЕ побудовані на нативному HTML5 Drag and Drop API. У
draggable_hook_builder.js слухач вішається на pointerdown/pointermove/pointerup,
а на mousedown/mousemove/mouseup перемикається автоматично — це внутрішня
перевірка useMouseEvents для Firefox без touch-підтримки, коли переданий
iframeWindow, а не параметр, який задає розробник. Тому й тач-екрани підтримуються
нативно — є навіть окремий параметр touchDelay.
Правильно. Для дошки з колонками (найближчий кейс до задачі вище) —
useSortable:
import { useSortable } from "@web/core/utils/sortable_owl";
class TaskBoard extends Component {
static template = "my_module.TaskBoard";
setup() {
this.orm = useService("orm");
this.rootRef = useRef("root");
useSortable({
ref: this.rootRef,
elements: ".o_task_card",
groups: ".o_task_column",
connectGroups: true,
handle: ".o_task_handle",
cursor: "move",
onDrop: async ({ element, parent }) => {
const taskId = Number(element.dataset.id);
const stageId = Number(parent.dataset.id);
await this.orm.write("project.task", [taskId], { stage_id: stageId });
},
});
}
}
Тут ми задаємо ref — кореневий елемент (t-ref="root" у шаблоні), elements —
селектор карток всередині нього, groups — селектор контейнерів-колонок, connectGroups дозволяє
переносити картку в іншу колонку (без нього сортувати можна тільки всередині
одного контейнера). handle обмежує точку захоплення — корисно, якщо всередині картки
є кнопки, які не мають запускати драг. onDrop отримує не тільки element, а й
group, previous, next, parent — цього досить, щоб одразу переписати і
stage_id, і sequence. Плейсхолдер, підсвітка колонки під курсором, автоскрол —
усе це вже всередині хука, за замовчуванням.
Саме так, до речі, влаштований kanban-view в самому ядрі: kanban_renderer.js викликає
useSortable двічі — один раз для карток (elements: ".o_draggable",
groups: ".o_kanban_group", connectGroups), другий раз для самих колонок
(elements: ".o_group_draggable", handle: ".o_column_title").
Якщо задача не «впорядкувати список», а вільне позиціювання — маркер на карті, стіл
на плані залу — сортування не підходить. Я б і тут не хапався за найочевидніший
варіант: публічний useDraggable (@web/core/utils/draggable.js) виглядає як готове
рішення, але в нього followCursor завжди true — хук сам пересуває елемент за
курсором, з офсетом і клемпінгом до контейнера, а публічного параметра, щоб це
вимкнути, немає. Якщо потрібні сирі координати без автопозиціювання — доведеться
зібрати свій хук на тому ж білдері makeDraggableHook
(draggable_hook_builder_owl.js) і вимкнути followCursor через onComputeParams:
import { pick } from "@web/core/utils/objects";
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder_owl";
const useMovable = makeDraggableHook({
name: "useMovable",
onComputeParams: ({ ctx }) => {
ctx.followCursor = false;
},
onWillStartDrag: ({ ctx }) => pick(ctx.current, "element"),
onDrag: ({ ctx }) => pick(ctx.current, "element"),
onDrop: ({ ctx }) => pick(ctx.current, "element"),
});
У setup() компонента синтаксис виклику той самий, що і для публічного
useDraggable, — просто хук тепер називається useMovable:
useMovable({
ref: this.mapRef,
elements: ".o_movable_marker",
onDrag: ({ element, x, y }) => {
element.style.left = `${x}px`;
element.style.top = `${y}px`;
},
onDrop: ({ element }) => {
// тут x/y уже кінцеві — записуємо в модель
},
});
План залу в POS (floor_screen.js) зроблено так само: не через публічний
useDraggable, а через свій локальний хук на тому ж білдері — той самий патерн, що
і вище, з вимкненим followCursor. onDrag отримує { element, x, y } і напряму
рахує позицію столу на карті, а onDrop фіксує координати в restaurant.table. Ну
ок, різниця з нативним варіантом — рядків двадцять коду. Але саме ці двадцять рядків
— плейсхолдер, автоскрол, робота на тач-екрані, обмеження за handle — і є те, що
довелось би писати руками при виборі нативного шляху.
Тема за матеріалом https://www.cybrosys.com/blog/how-to-implement-drag-and-drop-in-owl-components. Код і поведінку перевірено на Odoo 19.0.