Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions web/src/components/AddToQueueSplitButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* The split add button of the add/edit tabs: the primary button queues (or
* saves the edit) exactly as the plain button did, and a joined dropdown
* opens the secondary actions (queue onto the newest build plate, or download
* the design directly as STL or 3MF). In edit mode the dropdown is hidden:
* the secondary actions only apply to a new design. The component is
* presentation only; the owning tab performs every action.
*/

withDefaults(
defineProps<{
/** Whether the tab is editing an existing queue entry. */
editing: boolean;
/** Disables the primary button and the dropdown alike. */
disabled?: boolean;
/** Button size, matching whatever the tab's plain button used. */
size?: string;
}>(),
{ disabled: false, size: 'large' },
);

const emit = defineEmits<{
add: [];
addToPlate: [];
download: [format: 'stl' | '3mf'];
}>();
</script>

<template>
<div class="d-flex split-root">
<v-btn
color="primary"
variant="flat"
:size="size"
class="flex-grow-1"
:class="{ 'split-main': !editing }"
:disabled="disabled"
@click="emit('add')"
>
{{ editing ? 'Save changes' : 'Add to queue' }}
</v-btn>
<v-btn
v-if="!editing"
color="primary"
variant="flat"
:size="size"
class="split-arrow"
:disabled="disabled"
aria-label="More ways to use this design"
>
<v-icon icon="mdi-chevron-down" />
<v-menu activator="parent">
<v-list density="compact">
<v-list-item title="Add to newest build plate" @click="emit('addToPlate')" />
<v-list-item title="Download STL" @click="emit('download', 'stl')" />
<v-list-item title="Download 3MF" @click="emit('download', '3mf')" />
</v-list>
</v-menu>
</v-btn>
</div>
</template>

<style scoped>
.split-main {
/* !important: the host tab's rounded-lg utility class sets all corners with
!important, and the shared edge must stay square. */
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-arrow {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-left: 1px solid rgba(255, 255, 255, 0.2);
min-width: 44px;
padding: 0;
margin-left: 0;
}
.split-root {
gap: 0;
}
</style>
56 changes: 50 additions & 6 deletions web/src/components/BaseplateTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string[]>([]);

async function downloadDesign(format: 'stl' | '3mf'): Promise<void> {
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();
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -425,11 +456,24 @@ const drawerMode = computed(() => app.viewingDrawerId !== null);
<v-alert v-if="saveError" type="error" class="mt-4" density="compact">
{{ saveError }}
</v-alert>
<v-alert
v-if="downloadWarnings.length > 0"
type="warning"
variant="tonal"
density="compact"
class="mt-4"
>
<p v-for="warning in downloadWarnings" :key="warning" class="mb-1">{{ warning }}</p>
</v-alert>

<div class="d-flex ga-2 mt-4">
<v-btn color="primary" variant="flat" size="large" class="flex-grow-1" @click="saveEntry">
{{ editingEntry !== null ? 'Save changes' : 'Add to queue' }}
</v-btn>
<AddToQueueSplitButton
class="flex-grow-1"
:editing="editingEntry !== null"
@add="saveEntry"
@add-to-plate="addToPlate"
@download="downloadDesign"
/>
<v-btn v-if="editingEntry !== null" variant="outlined" size="large" @click="cancelEdit">
Cancel edit
</v-btn>
Expand Down
111 changes: 106 additions & 5 deletions web/src/components/MainPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,96 @@ function rowTitleFull(entry: QueueEntry): string {
const selectedIds = ref<Set<string>>(new Set());
const plateCounts = ref<Map<string, number>>(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<string | null>(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);
Expand Down Expand Up @@ -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)"
>
<v-icon icon="mdi-view-grid-outline" size="18" class="mr-2 text-medium-emphasis" />
<span class="row-text">
Expand All @@ -259,8 +350,11 @@ function removeRow(entry: QueueEntry): void {
<v-chip size="small" variant="tonal" color="primary">
{{ row.descriptor.counts.done }} / {{ row.descriptor.counts.total }} printed
</v-chip>
<v-icon icon="mdi-chevron-right" size="20" class="text-medium-emphasis" />
<div class="row-actions d-flex ga-1 justify-end" @click.stop>
<v-btn icon size="small" variant="text" @click="app.openDrawer(row.group.id)">
<v-icon icon="mdi-pencil-outline" size="18" />
<v-tooltip activator="parent" location="bottom">Edit drawer</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
Expand All @@ -279,14 +373,14 @@ function removeRow(entry: QueueEntry): void {
class="qrow"
:class="{ selected: selectedIds.has(row.entry.id), grouped: row.grouped }"
role="button"
@click="editRow(row.entry)"
@click="onRowClick(row.entry, $event)"
>
<template v-for="entry in [row.entry]" :key="entry.id">
<v-checkbox-btn
:model-value="selectedIds.has(entry.id)"
density="compact"
class="flex-grow-0 row-check"
@click.stop="toggleSelected(entry)"
@click.stop="onRowClick(entry, $event)"
/>
<span class="row-text">
<span class="row-title" :title="rowTitleFull(entry)">
Expand Down Expand Up @@ -332,6 +426,10 @@ function removeRow(entry: QueueEntry): void {
</div>
<div v-else></div>
<div class="row-actions d-flex ga-1 justify-end" @click.stop>
<v-btn icon size="small" variant="text" @click="editRow(entry)">
<v-icon icon="mdi-pencil-outline" size="18" />
<v-tooltip activator="parent" location="bottom">Edit</v-tooltip>
</v-btn>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
Expand Down Expand Up @@ -414,6 +512,7 @@ function removeRow(entry: QueueEntry): void {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
cursor: pointer;
user-select: none;
background: rgb(var(--v-theme-surface));
}

Expand All @@ -429,6 +528,7 @@ function removeRow(entry: QueueEntry): void {
border: 1px solid rgba(var(--v-theme-primary), 0.4);
border-radius: 10px;
cursor: pointer;
user-select: none;
background: rgba(var(--v-theme-primary), 0.04);
}

Expand Down Expand Up @@ -463,7 +563,8 @@ function removeRow(entry: QueueEntry): void {
border-color: rgb(var(--v-theme-primary));
}

.qrow.selected {
.qrow.selected,
.group-header.selected {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.08);
}
Expand Down
Loading
Loading