diff --git a/client_v2/locales/en/common.json b/client_v2/locales/en/common.json index 941d0f60..a1bc7120 100644 --- a/client_v2/locales/en/common.json +++ b/client_v2/locales/en/common.json @@ -322,7 +322,7 @@ "library.usedAgo": "used {{time}} ago", "loading": "Loading", "locations.addLocation": "Add location", - "locations.dragHint": "Drag spools between locations to reassign them", + "locations.dragHint": "Drag spools between locations to reassign them, or within a location to reorder them", "locations.dropHere": "drop spools here", "locations.errorEmpty": "Location name cannot be empty", "locations.errorExists": "A location with that name already exists", diff --git a/client_v2/package-lock.json b/client_v2/package-lock.json index e4269784..3971a26d 100644 --- a/client_v2/package-lock.json +++ b/client_v2/package-lock.json @@ -12,6 +12,7 @@ "konva": "^10.3.0", "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", + "svelte-dnd-action": "^0.9.74", "svelte-konva": "^1.0.1" }, "devDependencies": { @@ -1998,6 +1999,15 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-dnd-action": { + "version": "0.9.74", + "resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.74.tgz", + "integrity": "sha512-zn//d8plF73Y05joRdj0g9S7Z8KY5zoTyzjHtnaJMqQP//6U6smHAHi0ArZWi60bxpoOwo+im8gKhK/M7Eq1pQ==", + "license": "MIT", + "peerDependencies": { + "svelte": ">=3.23.0 || ^5.0.0-next.0" + } + }, "node_modules/svelte-konva": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/svelte-konva/-/svelte-konva-1.0.1.tgz", diff --git a/client_v2/package.json b/client_v2/package.json index b0735b41..00306687 100644 --- a/client_v2/package.json +++ b/client_v2/package.json @@ -30,6 +30,7 @@ "konva": "^10.3.0", "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", + "svelte-dnd-action": "^0.9.74", "svelte-konva": "^1.0.1" } } diff --git a/client_v2/src/routes/locations/+page.svelte b/client_v2/src/routes/locations/+page.svelte index 0ac19171..da999c1e 100644 --- a/client_v2/src/routes/locations/+page.svelte +++ b/client_v2/src/routes/locations/+page.svelte @@ -9,15 +9,19 @@ import { weightAuto } from '$lib/utils/format'; import * as m from '$lib/paraglide/messages'; import { getSettings, setSetting, parseSetting } from '$lib/api/settings'; + import { dndzone, type DndEvent } from 'svelte-dnd-action'; + import { flip } from 'svelte/animate'; import Plus from '@lucide/svelte/icons/plus'; import GripVertical from '@lucide/svelte/icons/grip-vertical'; import Trash2 from '@lucide/svelte/icons/trash-2'; const NO_LOCATION = 'No location'; + const FLIP = 160; - let draggingId = $state(null); - let dragOver = $state(null); - let draggingShelf = $state(null); + // A location card. `id` is what svelte-dnd-action keys on; it equals `name`, + // the location's display name (the NO_LOCATION sentinel for the unlocated + // bucket). `spools` is the card's own drop zone, in display order. + type Shelf = { id: string; name: string; spools: Spool[] }; let editingLocation = $state(null); let editValue = $state(''); @@ -33,9 +37,25 @@ let spools = $state([]); let settingOrder = $state([]); let settingsLoaded = $state(false); + // Per-location custom spool order, keyed by the stored location name (EMPTY for + // "No location"), each an array of spool ids. Source of truth for the order of + // spools within a card. Shared with the old client's `locations_spoolorders`. + let spoolOrders = $state>({}); + + // The live drag state. `shelves` is the structure the drag zones bind to and + // mutate directly; it is rebuilt from the data above whenever nothing is being + // dragged. `dragging` pauses that rebuild so an in-flight drag isn't clobbered. + // Shelf dragging is gated to the grip handle by keeping the grid zone disabled + // until a grip press enables it (svelte-dnd-action's drag-handle pattern), so + // it doesn't hijack pointer presses on the spool zones nested inside each card. + let shelves = $state([]); + let dragging = $state(false); + let shelvesDragDisabled = $state(true); + let displayNames = $derived(shelves.map((sh) => sh.name)); // Map between the display sentinel and the stored empty-string marker. const toStored = (names: string[]) => names.map((n) => (n === NO_LOCATION ? EMPTY : n)); + const storedKey = (name: string) => (name === NO_LOCATION ? EMPTY : name); async function loadSpools() { try { @@ -56,6 +76,7 @@ try { const s = await getSettings(); settingOrder = parseSetting(s.locations, []).filter((l) => l != null); + spoolOrders = parseSetting>(s.locations_spoolorders, {}); } catch (e) { console.error('Failed to load locations setting', e); } finally { @@ -75,87 +96,113 @@ return off; }); - let spoolsByLoc = $derived.by(() => { - const map = new Map(); - for (const s of spools) { - const loc = s.location || NO_LOCATION; - if (!map.has(loc)) map.set(loc, []); - map.get(loc)!.push(s); - } - return map; - }); - // The full display order: the saved setting order (empty string → the // "No location" bucket), then any location discovered from spools that isn't // in the setting yet. The "No location" bucket only shows when unlocated // spools exist, and defaults to the front when the setting doesn't place it. - let orderedNames = $derived.by(() => { + function computeOrderedNames(present: Set): string[] { const all: string[] = []; for (const l of settingOrder) { const name = l === EMPTY ? NO_LOCATION : l; if (!all.includes(name)) all.push(name); } - if (spoolsByLoc.has(NO_LOCATION) && !all.includes(NO_LOCATION)) all.unshift(NO_LOCATION); - for (const l of spoolsByLoc.keys()) if (l !== NO_LOCATION && !all.includes(l)) all.push(l); - return all.filter((n) => n !== NO_LOCATION || spoolsByLoc.has(NO_LOCATION)); - }); + if (present.has(NO_LOCATION) && !all.includes(NO_LOCATION)) all.unshift(NO_LOCATION); + for (const name of present) if (name !== NO_LOCATION && !all.includes(name)) all.push(name); + return all.filter((n) => n !== NO_LOCATION || present.has(NO_LOCATION)); + } - let shelves = $derived(orderedNames.map((name) => ({ name, spools: spoolsByLoc.get(name) ?? [] }))); + // Sort a location's spools by its saved custom order; any spool not yet in the + // order keeps its incoming (id-sorted) position at the end. Array.sort is + // stable, so ties preserve that order. + function orderSpools(name: string, list: Spool[]): Spool[] { + const order = spoolOrders[storedKey(name)]; + if (!order?.length) return list; + const rank = (id: number) => { + const i = order.indexOf(id); + return i === -1 ? Number.MAX_SAFE_INTEGER : i; + }; + return [...list].sort((a, b) => rank(a.id) - rank(b.id)); + } - // Keep the setting in sync so it always reflects the displayed order, - // persisting locations discovered from spools. It settles after a reorder - // (when setting == display order already, it's a no-op). + function buildShelves(): Shelf[] { + const byLoc = new Map(); + for (const s of spools) { + const name = s.location || NO_LOCATION; + if (!byLoc.has(name)) byLoc.set(name, []); + byLoc.get(name)!.push(s); + } + return computeOrderedNames(new Set(byLoc.keys())).map((name) => ({ + id: name, + name, + spools: orderSpools(name, byLoc.get(name) ?? []) + })); + } + + // Rebuild the displayed cards from the data whenever it changes, except while a + // drag is in progress (the drag handlers own `shelves` then). Also keeps the + // `locations` setting reflecting the displayed order so locations discovered + // from spools are persisted; it settles once the setting matches. $effect(() => { - if (!settingsLoaded) return; - const stored = toStored(orderedNames); - if (JSON.stringify(stored) !== JSON.stringify(settingOrder)) { - saveOrder(stored); - } + if (!settingsLoaded || dragging) return; + const desired = buildShelves(); + shelves = desired; + const stored = toStored(desired.map((sh) => sh.name)); + if (JSON.stringify(stored) !== JSON.stringify(settingOrder)) saveOrder(stored); }); - function startShelfDrag(e: DragEvent, name: string) { - draggingShelf = name; - const shelfEl = (e.currentTarget as HTMLElement).closest('.shelf'); - if (shelfEl && e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - // Drag the whole card, not just the grip handle. - e.dataTransfer.setDragImage(shelfEl, 20, 20); + function shelfConsider(e: CustomEvent>) { + dragging = true; + shelves = e.detail.items; + } + + function shelfFinalize(e: CustomEvent>) { + shelves = e.detail.items; + dragging = false; + shelvesDragDisabled = true; + saveOrder(toStored(shelves.map((sh) => sh.name))); + } + + function spoolConsider(idx: number, e: CustomEvent>) { + dragging = true; + shelves[idx].spools = e.detail.items; + } + + function spoolFinalize(idx: number, e: CustomEvent>) { + shelves[idx].spools = e.detail.items; + dragging = false; + commitSpoolLayout(); + } + + // Persist the outcome of a spool drag from the current card layout: move any + // spool that now sits in a different card to that location, then save every + // card's spool order. Diff-based, so the duplicate finalize fired on the other + // zone of a cross-card move is a harmless no-op. + function commitSpoolLayout() { + const changed: { id: number; location: string }[] = []; + const next = spools.map((sp) => { + const shelf = shelves.find((sh) => sh.spools.some((x) => x.id === sp.id)); + if (!shelf) return sp; + const loc = storedKey(shelf.name); + if ((sp.location || '') === loc) return sp; + changed.push({ id: sp.id, location: loc }); + return { ...sp, location: loc }; + }); + if (changed.length) { + spools = next; + for (const c of changed) { + inventory.patchSpool(c.id, { location: c.location }); + spoolSource.saveSpool(c.id, { location: c.location }).catch((e) => console.error('Move failed', e)); + } } - } - // Live-reorder when the held card enters another (matches the old client). - // Fires on dragenter (once per card entered) rather than continuously on - // dragover, so it settles instead of oscillating between adjacent cards. - // `to` is taken from the pre-removal list so a rightward move lands *after* - // the target (letting a card reach the last slot); a leftward move lands - // before it. The "No location" bucket participates like any other card. - function reorderShelves(target: string) { - if (draggingShelf == null || draggingShelf === target) return; - const list = [...orderedNames]; - const from = list.indexOf(draggingShelf); - const to = list.indexOf(target); - if (from < 0 || to < 0 || from === to) return; - list.splice(from, 1); - list.splice(to, 0, draggingShelf); - settingOrder = toStored(list); - } - - function endShelfDrag() { - if (draggingShelf != null) - setSetting('locations', settingOrder).catch((e) => console.error('Failed to save location order', e)); - draggingShelf = null; - dragOver = null; - } - - function onDrop(loc: string) { - if (draggingId != null) { - const target = loc === NO_LOCATION ? '' : loc; - inventory.patchSpool(draggingId, { location: target }); - spools = spools.map((s) => (s.id === draggingId ? { ...s, location: target } : s)); - spoolSource.saveSpool(draggingId, { location: target }).catch((e) => console.error('Move failed', e)); + const nextOrders: Record = { ...spoolOrders }; + for (const sh of shelves) nextOrders[storedKey(sh.name)] = sh.spools.map((s) => s.id); + if (JSON.stringify(nextOrders) !== JSON.stringify(spoolOrders)) { + spoolOrders = nextOrders; + setSetting('locations_spoolorders', nextOrders).catch((e) => + console.error('Failed to save spool order', e) + ); } - draggingId = null; - dragOver = null; } function openSpool(id: number) { @@ -164,7 +211,7 @@ } function addLocation() { - const existing = new Set(orderedNames); + const existing = new Set(displayNames); let n = 1; let name = m['locations.newShelf']({ n }); while (existing.has(name)) name = m['locations.newShelf']({ n: ++n }); @@ -207,7 +254,7 @@ renameError = m['locations.errorEmpty'](); return; } - if (orderedNames.includes(newName)) { + if (displayNames.includes(newName)) { renameError = m['locations.errorExists'](); return; } @@ -218,6 +265,14 @@ await spoolSource.renameLocation(oldName, newName); spools = spools.map((s) => (s.location === oldName ? { ...s, location: newName } : s)); } + // Carry the card's custom spool order over to the new name. + if (spoolOrders[oldName]) { + const { [oldName]: moved, ...rest } = spoolOrders; + spoolOrders = { ...rest, [newName]: moved }; + setSetting('locations_spoolorders', spoolOrders).catch((e) => + console.error('Failed to save spool order', e) + ); + } // Rename in place in the setting so the card keeps its position. saveOrder(settingOrder.map((l) => (l === oldName ? newName : l))); editingLocation = null; @@ -228,6 +283,12 @@ } + { + if (!dragging) shelvesDragDisabled = true; + }} +/> + {m['documentTitle.locations.list']()} @@ -239,35 +300,28 @@ -
- {#each shelves as shelf (shelf.name)} -
{ - if (draggingShelf != null) { - e.preventDefault(); - reorderShelves(shelf.name); - } - }} - ondragover={(e) => { - e.preventDefault(); - if (draggingShelf == null) dragOver = shelf.name; - }} - ondragleave={() => (draggingShelf == null && dragOver === shelf.name ? (dragOver = null) : null)} - ondrop={() => (draggingShelf != null ? endShelfDrag() : onDrop(shelf.name))} - > +
shelfConsider(e as CustomEvent>)} + onfinalize={(e) => shelfFinalize(e as CustomEvent>)} + > + {#each shelves as shelf, idx (shelf.id)} +
startShelfDrag(e, shelf.name)} - ondragend={endShelfDrag}> (shelvesDragDisabled = false)} + ontouchstart={() => (shelvesDragDisabled = false)}> {#if editingLocation === shelf.name} {renameError}
{/if} -
- {#each shelf.spools as s (s.id)} - {@const f = inventory.filamentById(s.filamentId)!} - {@const v = inventory.vendorOf(f)} -
(draggingId = s.id)} - ondragend={() => (draggingId = null)} - onclick={() => openSpool(s.id)} - onkeydown={(e) => e.key === 'Enter' && openSpool(s.id)} - > - -
-
- #{s.id} - {#if v.name !== '?'}{v.name}{' - '}{/if}{f.name} -
-
- {f.material}{' - '}{weightAuto(s.remaining)}{' / '}{weightAuto(f.weight)}{#if s.lastUsedLabel}{' - '}{m['locations.lastUsed']({ - time: s.lastUsedLabel - })}{/if} +
+
spoolConsider(idx, e as CustomEvent>)} + onfinalize={(e) => spoolFinalize(idx, e as CustomEvent>)} + > + {#each shelf.spools as s (s.id)} + {@const f = inventory.filamentById(s.filamentId)!} + {@const v = inventory.vendorOf(f)} +
openSpool(s.id)} + onkeydown={(e) => e.key === 'Enter' && openSpool(s.id)} + > + +
+
+ #{s.id} + {#if v.name !== '?'}{v.name}{' - '}{/if}{f.name} +
+
+ {f.material}{' - '}{weightAuto(s.remaining)}{' / '}{weightAuto(f.weight)}{#if s.lastUsedLabel}{' - '}{m['locations.lastUsed']({ + time: s.lastUsedLabel + })}{/if} +
-
- {/each} + {/each} +
{#if shelf.spools.length === 0} {m['locations.dropHere']()} {/if} @@ -408,12 +467,6 @@ flex-direction: column; min-height: 110px; } - .shelf.over { - border-color: var(--accent); - } - .shelf.dragging { - opacity: 0.5; - } .shelf-head { display: flex; align-items: center; @@ -427,6 +480,7 @@ cursor: grab; display: inline-flex; align-items: center; + touch-action: none; } .grip:active { cursor: grabbing; @@ -483,12 +537,20 @@ color: var(--danger-soft); background: var(--bg); } + /* Wraps the drop zone so the "drop here" hint can overlay an empty card + * without being a child of the zone (which maps its children 1:1 to items). */ + .shelf-body-wrap { + position: relative; + display: flex; + flex: 1; + } .shelf-body { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; flex: 1; + min-height: 42px; } .chip { display: flex; @@ -500,10 +562,14 @@ padding: 6px 9px; cursor: grab; user-select: none; + touch-action: none; } .chip:hover { border-color: var(--accent); } + .chip:active { + cursor: grabbing; + } .chip-info { display: flex; flex-direction: column; @@ -533,8 +599,13 @@ color: var(--danger-soft); } .empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 6px 14px; font-size: 11.5px; color: var(--text-faint); - padding: 6px 2px; + pointer-events: none; }