fix: open in slicer now provides a choice of available files

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-24 20:04:20 +02:00
1 parent 1fa98a830e
commit f3b3fb78e3
6 files changed
+166 -26

No files matched your search

+1
View File
@@ -1147,6 +1147,7 @@
"openInProvider": "Open in {{provider}}",
"openInSlicer": "Open in {{slicer}}",
"openInSlicerGeneric": "Open in Slicer",
"preparedPrintFile": "Prepared print",
"downloadPickerTitle": "Download",
"downloadAllZip": "Download all {{count}} files (.zip)",
"orDownloadOne": "or download one file",
+1
View File
@@ -1149,6 +1149,7 @@
"openInProvider": "Atverti „{{provider}}“",
"openInSlicer": "Atverti su {{slicer}}",
"openInSlicerGeneric": "Atverti pjaustyklėje",
"preparedPrintFile": "Paruoštas spaudinys",
"downloadPickerTitle": "Atsisiųsti",
"downloadAllZip": "Atsisiųsti visus failus ({{count}}, .zip)",
"orDownloadOne": "arba atsisiųsti vieną failą",
@@ -22,12 +22,11 @@ import { UnauthorizedError } from "../../api/client";
import { type Print, printsApi } from "../../api/prints";
import type { AuthUser } from "../../api/auth";
import { collectionsApi } from "../../api/collections";
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
import { useConfirm } from "../../components/ConfirmProvider";
import { importProviderInfo } from "../../constants/importProviders";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useDownloadPrint } from "./useDownloadPrint";
import { useOpenInSlicer } from "./useOpenInSlicer";
import SlicerFileMenu from "./SlicerFileMenu";
import DownloadPickerDialog from "./DownloadPickerDialog";
import AddToCollectionModal from "./AddToCollectionModal";
import EditModelModal from "./EditModelModal";
@@ -77,10 +76,10 @@ export default function ModelActionsMenu({
}: Props) {
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const slicerPreference = useSlicerPreference();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [slicerMenuAnchor, setSlicerMenuAnchor] = useState<HTMLElement | null>(null);
const [deleting, setDeleting] = useState(false);
const [removingFromCollection, setRemovingFromCollection] = useState(false);
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
@@ -156,11 +155,8 @@ export default function ModelActionsMenu({
};
const providerInfo = importProviderInfo(print.source_provider);
// "other" has no registered URL protocol to launch -- treated the same as no preference set.
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other");
const openInSlicerHref = slicerOption && print.slicer_url
? slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(print.slicer_url), print.slicer_filename ?? undefined)
: undefined;
const { slicerOption, targets: slicerTargets } = useOpenInSlicer(print);
const openInSlicerHref = slicerTargets.length === 1 ? slicerTargets[0].href : undefined;
return (
<>
@@ -207,14 +203,21 @@ export default function ModelActionsMenu({
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
</MenuItem>
<Divider />
{/* One target: a plain link to it. Several: this menu hands over to SlicerFileMenu,
anchored on the same trigger, to pick which. */}
<MenuItem
component="a"
href={openInSlicerHref}
onClick={() => {
if (slicerTargets.length > 1) {
setSlicerMenuAnchor(anchorEl);
closeMenu();
return;
}
closeMenu();
recordUse();
}}
disabled={!openInSlicerHref}
disabled={slicerTargets.length === 0}
>
<ListItemIcon><LaunchIcon fontSize="small" /></ListItemIcon>
<ListItemText>
@@ -237,6 +240,16 @@ export default function ModelActionsMenu({
)}
</Menu>
{slicerOption && slicerTargets.length > 1 && (
<SlicerFileMenu
anchorEl={slicerMenuAnchor}
onClose={() => setSlicerMenuAnchor(null)}
slicerLabel={slicerOption.label}
targets={slicerTargets}
onOpen={recordUse}
/>
)}
<DownloadPickerDialog
open={pickerOpen}
onClose={() => setPickerOpen(false)}
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Paper from "@mui/material/Paper";
@@ -10,19 +11,18 @@ import ButtonBase from "@mui/material/ButtonBase";
import FolderIcon from "@mui/icons-material/Folder";
import StorageIcon from "@mui/icons-material/Storage";
import LaunchIcon from "@mui/icons-material/Launch";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import DownloadIcon from "@mui/icons-material/Download";
import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import type { Print } from "../../api/prints";
import { printsApi } from "../../api/prints";
import type { AuthUser } from "../../api/auth";
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import { dividerBorderColor } from "../../theme";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useDownloadPrint } from "./useDownloadPrint";
import { useOpenInSlicer } from "./useOpenInSlicer";
import SlicerFileMenu from "./SlicerFileMenu";
import RollingNumber from "../../components/RollingNumber";
import { formatFileSize } from "../../utils/fileSize";
import AuthorHoverCard from "../../components/AuthorHoverCard";
@@ -43,23 +43,20 @@ type Props = {
/** The model detail page's right-hand summary card, sticky so it stays in view while the
* description/tags column scrolls: the full title, author (jumps to their author page), category (jumps back
* to the Models grid filtered to it), "Open in {Slicer}" (only when both a preference is set
* and this print has a slicer_url -- same condition ModelActionsMenu's menu item uses),
* and this print has a slicer_url, and a pick of which file when there are several -- see
* useOpenInSlicer, shared with ModelActionsMenu's menu item),
* "Download model files" (the same picker-or-direct-download flow as ModelActionsMenu's
* Download, via useDownloadPrint so the two can't drift), view/print counts, and -- only for an
* actually-imported print, per source_provider -- when it was imported. */
export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized, onUpdated, viewer }: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
const slicerPreference = useSlicerPreference();
const viewerAvatarUrl = useGravatarUrl(viewer?.email, 56);
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates, recordUse } =
useDownloadPrint(print, onUnauthorized, onUpdated);
// "other" has no registered URL protocol to launch -- treated the same as no preference set.
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other");
const openInSlicerHref = slicerOption && print.slicer_url
? slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(print.slicer_url), print.slicer_filename ?? undefined)
: undefined;
const { slicerOption, targets: slicerTargets } = useOpenInSlicer(print);
const [slicerMenuAnchor, setSlicerMenuAnchor] = useState<HTMLElement | null>(null);
const goToCategory = () => {
if (!print.category_id) return;
@@ -188,11 +185,12 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
</Box>
)}
{openInSlicerHref && (
{slicerOption && slicerTargets.length > 0 && (
<Button
component="a"
href={openInSlicerHref}
onClick={recordUse}
// One target: a plain link to it. Several: pick which (see SlicerFileMenu).
{...(slicerTargets.length === 1
? { component: "a" as const, href: slicerTargets[0].href, onClick: recordUse }
: { onClick: (e: React.MouseEvent<HTMLElement>) => setSlicerMenuAnchor(e.currentTarget), endIcon: <ArrowDropDownIcon /> })}
startIcon={<LaunchIcon fontSize="small" />}
fullWidth
sx={{
@@ -203,9 +201,19 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
"&:hover": { bgcolor: "action.hover", borderColor: "primary.dark" },
}}
>
{t("models:detail.openInSlicer", { slicer: slicerOption!.label })}
{t("models:detail.openInSlicer", { slicer: slicerOption.label })}
</Button>
)}
{slicerOption && slicerTargets.length > 1 && (
<SlicerFileMenu
anchorEl={slicerMenuAnchor}
onClose={() => setSlicerMenuAnchor(null)}
slicerLabel={slicerOption.label}
targets={slicerTargets}
onOpen={recordUse}
matchAnchorWidth
/>
)}
<Button
onClick={handleDownload}
@@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import ListSubheader from "@mui/material/ListSubheader";
import Box from "@mui/material/Box";
import PrintIcon from "@mui/icons-material/Print";
import PlateThumbnail from "../../components/media/PlateThumbnail";
import { fileRowText } from "./fileRowText";
import type { SlicerTarget } from "./useOpenInSlicer";
type Props = {
anchorEl: HTMLElement | null;
onClose: () => void;
slicerLabel: string;
targets: SlicerTarget[];
/** Called as a target's link is followed (e.g. to bump the print count). */
onOpen: () => void;
/** Opens directly below the anchor at exactly its width -- for a full-width button, so the menu
* reads as that button's dropdown. Off for a small anchor like an icon button. */
matchAnchorWidth?: boolean;
};
/** "Open in {Slicer}" for a model with more than one file: pick which one to hand the slicer,
* rather than always the first. Each row is a real link to the slicer's URL protocol. */
export default function SlicerFileMenu({ anchorEl, onClose, slicerLabel, targets, onOpen, matchAnchorWidth = false }: Props) {
const { t } = useTranslation(["models"]);
const paperSx = matchAnchorWidth && anchorEl ? { width: anchorEl.offsetWidth } : { maxWidth: 360 };
return (
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={onClose}
{...(matchAnchorWidth && {
anchorOrigin: { vertical: "bottom", horizontal: "left" },
transformOrigin: { vertical: "top", horizontal: "left" },
})}
slotProps={{ paper: { sx: paperSx } }}
>
<ListSubheader sx={{ lineHeight: "32px" }}>{t("models:detail.openInSlicer", { slicer: slicerLabel })}</ListSubheader>
{targets.map(target => {
const text = target.plate
? fileRowText(t, target.filename, target.index)
: { primary: t("models:detail.preparedPrintFile"), secondary: target.filename };
return (
<MenuItem
key={target.key}
component="a"
href={target.href}
title={target.filename}
onClick={() => {
onOpen();
onClose();
}}
>
<ListItemIcon sx={{ minWidth: 44 }}>
{target.plate ? (
<PlateThumbnail plate={target.plate} />
) : (
<Box sx={{ width: 32, height: 32, display: "flex", alignItems: "center", justifyContent: "center" }}>
<PrintIcon fontSize="small" />
</Box>
)}
</ListItemIcon>
<ListItemText
{...text}
primaryTypographyProps={{ variant: "body2", noWrap: true }}
secondaryTypographyProps={{ variant: "caption", noWrap: true }}
/>
</MenuItem>
);
})}
</Menu>
);
}
@@ -0,0 +1,41 @@
import { useMemo } from "react";
import { type Plate, type Print, printsApi } from "../../api/prints";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
/** One thing "Open in {Slicer}" can open: the attached prepared print, or one of the model's
* files (`plate` set). */
export type SlicerTarget = { key: string; href: string; filename: string; index: number; plate: Plate | null };
/** Everything "Open in {Slicer}" needs, shared by ModelSidePanel's button and ModelActionsMenu's
* item. `slicerOption` is null without a usable preference ("other" has no URL protocol to
* launch). With one target the caller links straight to it; with several (a model with multiple
* files, e.g. several MakerWorld print profiles, or a prepared print alongside the files) it
* offers a pick instead of silently opening the first. */
export function useOpenInSlicer(print: Print) {
const slicerPreference = useSlicerPreference();
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other") ?? null;
const targets = useMemo<SlicerTarget[]>(() => {
if (!slicerOption || !print.slicer_url) return [];
const launch = (url: string, filename: string) => slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(url), filename);
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
const out: SlicerTarget[] = [];
// The backend's slicer_url is an attached prepared print, or else the first file -- which,
// when that file is itself sliced, comes with a slicer_filename carrying the right suffix
// (e.g. .gcode.3mf) for the slicer to treat it as such. Both are kept as-is here.
const slicerUrlIsPlate = sortedPlates.some(p => p.url === print.slicer_url);
if (!slicerUrlIsPlate) {
const filename = print.slicer_filename ?? "";
out.push({ key: "prepared", href: launch(print.slicer_url, filename), filename, index: -1, plate: null });
}
sortedPlates.forEach((plate, index) => {
const filename = plate.url === print.slicer_url && print.slicer_filename ? print.slicer_filename : plate.filename;
out.push({ key: plate.id, href: launch(plate.url, filename), filename: plate.filename, index, plate });
});
return out;
}, [print.plates, print.slicer_url, print.slicer_filename, slicerOption]);
return { slicerOption, targets };
}