diff --git a/web/src/components/AddToQueueSplitButton.vue b/web/src/components/AddToQueueSplitButton.vue new file mode 100644 index 0000000..867e0e9 --- /dev/null +++ b/web/src/components/AddToQueueSplitButton.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/web/src/components/BaseplateTab.vue b/web/src/components/BaseplateTab.vue index 61a46d2..7b97609 100644 --- a/web/src/components/BaseplateTab.vue +++ b/web/src/components/BaseplateTab.vue @@ -18,6 +18,8 @@ import { originOf, type QueueEntry } from '../engine/plan/types'; import { describeProduct } from '../engine/plan/rowDescriptor'; import { baseplateCellCount } from '../engine/baseplate/generator'; import { validateProduct } from '../engine/plan/planFile'; +import { downloadProduct3mf, downloadProductStl } from '../binDownloads'; +import AddToQueueSplitButton from './AddToQueueSplitButton.vue'; import BinViewport from './BinViewport.vue'; import BaseplateOptionsFields from './BaseplateOptionsFields.vue'; import ConnectionClipCard from './ConnectionClipCard.vue'; @@ -187,12 +189,41 @@ function saveEntry(): void { if (saveError.value !== null) return; app.stopEditing(); } else { - saveError.value = queue.add(product, quantity.value, cleanNotes); - if (saveError.value !== null) return; + const result = queue.add(product, quantity.value, cleanNotes); + saveError.value = result.problem; + if (result.problem !== null) return; } resetForm(); } +/** Queues the baseplate and moves it onto the newest build plate in one step. */ +function addToPlate(): void { + const cleanNotes = store.notes.trim(); + const result = queue.add(store.product, quantity.value, cleanNotes); + saveError.value = result.problem; + if (result.problem !== null) return; + queue.addEntryToNewestBatch(result.id, quantity.value); + resetForm(); +} + +// Direct downloads of the designed baseplate, without touching the queue. The +// warnings a generation can return are shown, never dropped. +const downloadWarnings = ref([]); + +async function downloadDesign(format: 'stl' | '3mf'): Promise { + saveError.value = null; + downloadWarnings.value = []; + try { + const product = store.product; + downloadWarnings.value = + format === 'stl' + ? await downloadProductStl(product) + : await downloadProduct3mf(product); + } catch (error) { + saveError.value = error instanceof Error ? error.message : 'The download failed.'; + } +} + function cancelEdit(): void { app.stopEditing(); resetForm(); @@ -238,7 +269,7 @@ function addClips(): void { if (clipSaveError.value !== null) return; app.stopEditing(); } else { - clipSaveError.value = queue.add(product, clipQuantity.value); + clipSaveError.value = queue.add(product, clipQuantity.value).problem; } } @@ -425,11 +456,24 @@ const drawerMode = computed(() => app.viewingDrawerId !== null); {{ saveError }} + +

{{ warning }}

+
- - {{ editingEntry !== null ? 'Save changes' : 'Add to queue' }} - + Cancel edit diff --git a/web/src/components/MainPage.vue b/web/src/components/MainPage.vue index 97d0c08..4fba670 100644 --- a/web/src/components/MainPage.vue +++ b/web/src/components/MainPage.vue @@ -91,6 +91,96 @@ function rowTitleFull(entry: QueueEntry): string { const selectedIds = ref>(new Set()); const plateCounts = ref>(new Map()); +// Anchor for shift-click range selection: the id of the last entry row the +// user clicked, if it is still rendered. +const lastClickedId = ref(null); + +function onRowClick(entry: QueueEntry, event: MouseEvent): void { + const orderedIds = queueRows.value + .filter((row) => row.kind !== 'group') + .map((row) => row.entry.id); + const anchorIndex = + lastClickedId.value !== null ? orderedIds.indexOf(lastClickedId.value) : -1; + if (event.shiftKey && anchorIndex !== -1) { + event.preventDefault(); + const clickedIndex = orderedIds.indexOf(entry.id); + const start = Math.min(anchorIndex, clickedIndex); + const end = Math.max(anchorIndex, clickedIndex); + const selecting = !selectedIds.value.has(entry.id); + const entriesById = new Map(queue.entries.map((e) => [e.id, e])); + const nextIds = new Set(selectedIds.value); + const nextCounts = new Map(plateCounts.value); + for (let i = start; i <= end; i++) { + const id = orderedIds[i]; + if (selecting) { + nextIds.add(id); + if (!nextCounts.has(id)) { + const target = entriesById.get(id); + if (target !== undefined) nextCounts.set(id, target.quantity); + } + } else { + nextIds.delete(id); + nextCounts.delete(id); + } + } + selectedIds.value = nextIds; + plateCounts.value = nextCounts; + } else { + toggleSelected(entry); + } + lastClickedId.value = entry.id; +} + +/** + * The ids of a group's plate rows: the consecutive entry rows following its + * header in queueRows, which mirrors partitionQueue's ordering. + */ +function groupEntryIds(group: Group): string[] { + const rows = queueRows.value; + const headerIndex = rows.findIndex( + (row) => row.kind === 'group' && row.group.id === group.id, + ); + if (headerIndex === -1) return []; + const ids: string[] = []; + for (let i = headerIndex + 1; i < rows.length; i++) { + const row = rows[i]; + if (row.kind !== 'entry' || !row.grouped) break; + ids.push(row.entry.id); + } + return ids; +} + +// Group header click: select all the group's plate rows, or deselect them +// all when every one is already selected. +function onGroupClick(group: Group): void { + const ids = groupEntryIds(group); + if (ids.length === 0) return; + const allSelected = ids.every((id) => selectedIds.value.has(id)); + const entriesById = new Map(queue.entries.map((e) => [e.id, e])); + const nextIds = new Set(selectedIds.value); + const nextCounts = new Map(plateCounts.value); + for (const id of ids) { + if (allSelected) { + nextIds.delete(id); + nextCounts.delete(id); + } else { + nextIds.add(id); + if (!nextCounts.has(id)) { + const target = entriesById.get(id); + if (target !== undefined) nextCounts.set(id, target.quantity); + } + } + } + selectedIds.value = nextIds; + plateCounts.value = nextCounts; + lastClickedId.value = ids[ids.length - 1]; +} + +function isGroupSelected(group: Group): boolean { + const ids = groupEntryIds(group); + return ids.length > 0 && ids.every((id) => selectedIds.value.has(id)); +} + function toggleSelected(entry: QueueEntry): void { const nextIds = new Set(selectedIds.value); const nextCounts = new Map(plateCounts.value); @@ -248,8 +338,9 @@ function removeRow(entry: QueueEntry): void { v-if="row.kind === 'group'" :key="`g-${row.group.id}`" class="group-header" + :class="{ selected: isGroupSelected(row.group) }" role="button" - @click="app.openDrawer(row.group.id)" + @click="onGroupClick(row.group)" > @@ -259,8 +350,11 @@ function removeRow(entry: QueueEntry): void { {{ row.descriptor.counts.done }} / {{ row.descriptor.counts.total }} printed -
+ + + Edit drawer +