Calendar chips can be dragged onto another in-range day (multi-day entries shift end_date by the same delta in one PATCH); day-editor rows get a drag handle to reorder within a day, patching sort_order only for changed positions. Continues/dropoff ghost chips and stay bands are not draggable; touch devices fall back to the existing click/edit flow. Each stay band now gets a deterministic colour from an 8-hue palette by chronological index, mirrored as dots in the summary Areas list.
129 lines
5.0 KiB
JavaScript
129 lines
5.0 KiB
JavaScript
// HTML5 drag-and-drop helpers, no libraries. Two flows:
|
|
// • Calendar — drag a day chip onto another in-range day to move that entry
|
|
// (shifts end_date by the same delta so multi-day spans stay intact).
|
|
// • Day editor — drag a row's handle to reorder entries within one day.
|
|
// Touch devices don't fire HTML5 DnD events, so both flows are desktop-only and
|
|
// degrade to the existing click/edit behaviour with no polyfill.
|
|
import { api } from '../api.js';
|
|
import { toast } from '../dom.js';
|
|
import { parseYMD, ymd, addDays } from '../format.js';
|
|
|
|
const DND_MIME = 'text/plain';
|
|
|
|
function dayDelta(fromYmd, toYmd) {
|
|
return Math.round((parseYMD(toYmd) - parseYMD(fromYmd)) / 86400000);
|
|
}
|
|
|
|
// ---------- Calendar: move an entry to another day ----------
|
|
|
|
// Mark a calendar chip as a drag source carrying its entry id.
|
|
export function makeChipDraggable(node, entry) {
|
|
node.setAttribute('draggable', 'true');
|
|
node.classList.add('cal-chip-draggable');
|
|
node.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData(DND_MIME, String(entry.id));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
node.classList.add('dragging');
|
|
});
|
|
node.addEventListener('dragend', () => node.classList.remove('dragging'));
|
|
}
|
|
|
|
// Wire an in-range day cell as a drop target. On drop the dragged entry moves to
|
|
// `targetDate` with sort_order = `targetCount` (appended after that day's
|
|
// entries); an entry with an end_date has it shifted by the same day-delta in
|
|
// the SAME patch (the backend rejects date > stored end_date otherwise).
|
|
export function makeDayDropTarget(cell, targetDate, targetCount, entriesById, tctx) {
|
|
cell.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
cell.classList.add('cal-day-drop');
|
|
});
|
|
cell.addEventListener('dragleave', (e) => {
|
|
if (!cell.contains(e.relatedTarget)) cell.classList.remove('cal-day-drop');
|
|
});
|
|
cell.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
cell.classList.remove('cal-day-drop');
|
|
const id = Number(e.dataTransfer.getData(DND_MIME));
|
|
if (!id) return;
|
|
const entry = entriesById.get(id);
|
|
if (!entry || entry.date === targetDate) return; // dropped on its own day
|
|
const patch = { date: targetDate, sort_order: targetCount };
|
|
if (entry.end_date) {
|
|
const delta = dayDelta(entry.date, targetDate);
|
|
patch.end_date = ymd(addDays(parseYMD(entry.end_date), delta));
|
|
}
|
|
try {
|
|
await api.entries.update(id, patch);
|
|
await tctx.refreshTrip();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- Day editor: reorder entries within a day ----------
|
|
|
|
// `rows`: [{ node, handle, entry }] in current display order. Dragging a row's
|
|
// handle over another row shows a top/bottom insertion indicator; on drop the
|
|
// new order is computed and sort_order PATCHed for ONLY the entries whose index
|
|
// changed, then `refresh()` re-renders (the editor stays open on the same day).
|
|
export function enableRowReorder(rows, refresh) {
|
|
let draggingId = null;
|
|
|
|
const clearAll = () => {
|
|
for (const r of rows) r.node.classList.remove('drop-before', 'drop-after', 'dragging');
|
|
};
|
|
|
|
for (const { node, handle, entry } of rows) {
|
|
handle.setAttribute('draggable', 'true');
|
|
handle.addEventListener('dragstart', (e) => {
|
|
draggingId = entry.id;
|
|
e.dataTransfer.setData(DND_MIME, String(entry.id));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setDragImage(node, 0, 0);
|
|
node.classList.add('dragging');
|
|
});
|
|
handle.addEventListener('dragend', () => { draggingId = null; clearAll(); });
|
|
|
|
node.addEventListener('dragover', (e) => {
|
|
if (draggingId == null || entry.id === draggingId) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
const rect = node.getBoundingClientRect();
|
|
const after = e.clientY - rect.top > rect.height / 2;
|
|
node.classList.toggle('drop-after', after);
|
|
node.classList.toggle('drop-before', !after);
|
|
});
|
|
node.addEventListener('dragleave', (e) => {
|
|
if (!node.contains(e.relatedTarget)) node.classList.remove('drop-before', 'drop-after');
|
|
});
|
|
node.addEventListener('drop', async (e) => {
|
|
if (draggingId == null || entry.id === draggingId) return;
|
|
e.preventDefault();
|
|
const after = node.classList.contains('drop-after');
|
|
const movedId = draggingId;
|
|
clearAll();
|
|
draggingId = null;
|
|
await commitReorder(rows, movedId, entry.id, after, refresh);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function commitReorder(rows, movedId, targetId, after, refresh) {
|
|
const moved = rows.find((r) => r.entry.id === movedId).entry;
|
|
const ordered = rows.map((r) => r.entry).filter((e) => e.id !== movedId);
|
|
const targetIndex = ordered.findIndex((e) => e.id === targetId);
|
|
ordered.splice(after ? targetIndex + 1 : targetIndex, 0, moved);
|
|
try {
|
|
for (let i = 0; i < ordered.length; i++) {
|
|
if (ordered[i].sort_order !== i) {
|
|
await api.entries.update(ordered[i].id, { sort_order: i });
|
|
}
|
|
}
|
|
await refresh();
|
|
} catch (err) {
|
|
toast(err.message);
|
|
}
|
|
}
|