From eda2e04599ad29f149370247aaf29045b46e0eb5 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 2 Aug 2026 21:26:08 +0200 Subject: [PATCH] Added option to open/slice files in local slicers when slicer api is enabled --- .../components/ModelViewerModal.test.tsx | 162 ++++++++++++++++++ frontend/src/components/ModelViewerModal.tsx | 145 ++++++++++++++-- frontend/src/i18n/locales/de.ts | 2 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/es.ts | 2 + frontend/src/i18n/locales/fr.ts | 2 + frontend/src/i18n/locales/it.ts | 2 + frontend/src/i18n/locales/ja.ts | 2 + frontend/src/i18n/locales/ko.ts | 2 + frontend/src/i18n/locales/pt-BR.ts | 2 + frontend/src/i18n/locales/ru.ts | 2 + frontend/src/i18n/locales/tr.ts | 2 + frontend/src/i18n/locales/zh-CN.ts | 2 + frontend/src/i18n/locales/zh-TW.ts | 2 + 14 files changed, 319 insertions(+), 12 deletions(-) diff --git a/frontend/src/__tests__/components/ModelViewerModal.test.tsx b/frontend/src/__tests__/components/ModelViewerModal.test.tsx index 8db3a65f3..ae8838682 100644 --- a/frontend/src/__tests__/components/ModelViewerModal.test.tsx +++ b/frontend/src/__tests__/components/ModelViewerModal.test.tsx @@ -8,6 +8,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/react'; import { render } from '../utils'; import { ModelViewerModal } from '../../components/ModelViewerModal'; import { setStreamToken } from '../../api/client'; +import { openInSlicer } from '../../utils/slicer'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; @@ -28,6 +29,10 @@ vi.mock('../../components/GcodeViewer', () => ({ ), })); +vi.mock('../../utils/slicer', () => ({ + openInSlicer: vi.fn(), +})); + const mockCapabilities = { has_model: true, has_gcode: true, @@ -528,4 +533,161 @@ describe('ModelViewerModal', () => { }); }); }); + + describe('slicer split button (#2725)', () => { + it('shows both slicers in the dropdown when Bambuddy is the default slicer', async () => { + server.use( + http.get('/api/v1/settings/', () => { + return HttpResponse.json({ use_slicer_api: true }); + }), + http.get('/api/v1/library/files/:id/plates', () => { + return HttpResponse.json(mockSinglePlateResponse); + }) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'More slicer options' })); + + await waitFor(() => { + expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument(); + expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument(); + }); + }); + + it('opens the selected local slicer from the Bambuddy dropdown', async () => { + server.use( + http.get('/api/v1/settings/', () => { + return HttpResponse.json({ use_slicer_api: true }); + }), + http.get('/api/v1/library/files/:id/plates', () => { + return HttpResponse.json(mockSinglePlateResponse); + }) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'More slicer options' })); + + const orcaItem = await screen.findByText('Open in OrcaSlicer'); + fireEvent.click(orcaItem); + + await waitFor(() => { + expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer'); + }); + }); + + it('shows only the non-preferred slicer in the dropdown for a desktop handoff', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'More slicer options' })); + + await waitFor(() => { + expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument(); + }); + expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument(); + }); + + it('offers Bambu Studio when the preferred desktop slicer is OrcaSlicer', async () => { + server.use( + http.get('/api/v1/settings/', () => { + return HttpResponse.json({ preferred_slicer: 'orcaslicer' }); + }) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'More slicer options' })); + + await waitFor(() => { + expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument(); + }); + expect(screen.queryByText('Open in OrcaSlicer')).not.toBeInTheDocument(); + }); + + it('does not render a split chevron when the file cannot open in a slicer', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeDisabled(); + }); + + expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument(); + }); + + it('renders a plain Slice button without a split chevron for non-handoff files', async () => { + server.use( + http.get('/api/v1/settings/', () => { + return HttpResponse.json({ use_slicer_api: true }); + }) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument(); + }); + + expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/ModelViewerModal.tsx b/frontend/src/components/ModelViewerModal.tsx index 0426691b2..79ef7046f 100644 --- a/frontend/src/components/ModelViewerModal.tsx +++ b/frontend/src/components/ModelViewerModal.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; -import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react'; +import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react'; import { ModelViewer } from './ModelViewer'; import { GcodeViewer } from './GcodeViewer'; import { Button } from './Button'; @@ -32,6 +32,98 @@ interface Capabilities { filament_colors: string[]; } +interface SlicerSplitButtonProps { + icon: ReactNode; + label: string; + dropdownLabel: string; + onPrimary: () => void; + disabled?: boolean; + items: Array<{ key: string; label: string; onClick: () => void }>; +} + +// Split button: the primary part runs the default slicer action, the chevron +// opens a dropdown with the other slicer options. Outside click or Escape +// (non-propagating) closes the dropdown. +function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = false, items }: SlicerSplitButtonProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + setOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + return ( +
+
+ + +
+ {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) { const { t } = useTranslation(); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings }); @@ -296,27 +388,38 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType, ); - const handleOpenInSlicer = async () => { + const slicerDropdownTypes: SlicerType[] = useBambuddySlicer + ? ['bambu_studio', 'orcaslicer'] + : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer']; + const slicerName = (slicer: SlicerType) => + slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio'); + const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({ + key: slicer, + label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }), + onClick: () => handleOpenInSlicer(slicer), + })); + + const handleOpenInSlicer = async (slicer: SlicerType) => { if (!canOpenInSlicer) return; const filename = title || 'model'; try { if (isLibrary) { const { token } = await api.createLibrarySlicerToken(libraryFileId!); const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename); - openInSlicer(`${window.location.origin}${path}`, preferredSlicer); + openInSlicer(`${window.location.origin}${path}`, slicer); } else { const { token } = await api.createArchiveSlicerToken(archiveId!); const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename); - openInSlicer(`${window.location.origin}${path}`, preferredSlicer); + openInSlicer(`${window.location.origin}${path}`, slicer); } } catch { // Fallback to direct URL (works when auth is disabled) if (isLibrary) { const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`; - openInSlicer(downloadUrl, preferredSlicer); + openInSlicer(downloadUrl, slicer); } else { const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`; - openInSlicer(downloadUrl, preferredSlicer); + openInSlicer(downloadUrl, slicer); } } }; @@ -344,12 +447,30 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
{useBambuddySlicer ? ( - + canOpenInSlicer ? ( + } + label={t('slice.action')} + dropdownLabel={t('modelViewer.moreSlicerOptions')} + onPrimary={() => onSliceWithBambuddy?.()} + items={slicerDropdownItems} + /> + ) : ( + + ) + ) : canOpenInSlicer ? ( + } + label={t('modelViewer.openInSlicer')} + dropdownLabel={t('modelViewer.moreSlicerOptions')} + onPrimary={() => handleOpenInSlicer(preferredSlicer)} + items={slicerDropdownItems} + /> ) : ( - diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 89aa81f66..c6616ef79 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -5293,6 +5293,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: 'Im Slicer öffnen', + openInSlicerWith: 'In {{slicer}} öffnen', + moreSlicerOptions: 'Weitere Slicer-Optionen', tabs: { model: '3D-Modell', gcode: 'G-Code Vorschau', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 60fcc38a8..7109ad987 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5337,6 +5337,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: 'Open in Slicer', + openInSlicerWith: 'Open in {{slicer}}', + moreSlicerOptions: 'More slicer options', tabs: { model: '3D Model', gcode: 'G-code Preview', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 6ecb0a0fb..e0fc69edf 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -5302,6 +5302,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: 'Abrir en el laminador', + openInSlicerWith: 'Abrir en {{slicer}}', + moreSlicerOptions: 'Más opciones de laminador', tabs: { model: 'Modelo 3D', gcode: 'Vista previa de G-code', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 05d0df06d..2cbbb776a 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -5283,6 +5283,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: 'Ouvrir dans le Slicer', + openInSlicerWith: 'Ouvrir dans {{slicer}}', + moreSlicerOptions: "Plus d'options de slicer", tabs: { model: 'Modèle 3D', gcode: 'Aperçu G-code', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 1b7eefbb1..320cebffa 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -5282,6 +5282,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: 'Apri nello slicer', + openInSlicerWith: 'Apri in {{slicer}}', + moreSlicerOptions: 'Altre opzioni dello slicer', tabs: { model: 'Modello 3D', gcode: 'Anteprima G-code', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 68efb783d..c88097278 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -5294,6 +5294,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: 'スライサーで開く', + openInSlicerWith: '{{slicer}}で開く', + moreSlicerOptions: 'その他のスライサーオプション', tabs: { model: '3Dモデル', gcode: 'G-codeプレビュー', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index eaa961a8e..d40307d11 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -5029,6 +5029,8 @@ export default { }, modelViewer: { openInSlicer: '슬라이서에서 열기', + openInSlicerWith: '{{slicer}}에서 열기', + moreSlicerOptions: '슬라이서 옵션 더 보기', tabs: { model: '3D 모델', gcode: 'G-code 미리보기' diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index c7ad68cf6..11b8ba9d2 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -5282,6 +5282,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: 'Abrir no Slicer', + openInSlicerWith: 'Abrir em {{slicer}}', + moreSlicerOptions: 'Mais opções de fatiador', tabs: { model: 'Modelo 3D', gcode: 'Pré-visualização G-code', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index d7b543d8e..2cd9f71cc 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -5017,6 +5017,8 @@ export default { }, modelViewer: { openInSlicer: "Открыть в слайсере", + openInSlicerWith: "Открыть в {{slicer}}", + moreSlicerOptions: "Другие варианты слайсера", tabs: { model: "3D-модель", gcode: "Предпросмотр G-code", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 2f5f5f691..0b73566e9 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -5258,6 +5258,8 @@ export default { // Model Görüntüleyici modelViewer: { openInSlicer: 'Dilimleyicide Aç', + openInSlicerWith: '{{slicer}} ile aç', + moreSlicerOptions: 'Diğer dilimleyici seçenekleri', tabs: { model: '3B Model', gcode: 'G-kod Önizleme', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 4bf2b94f2..bec4f7fca 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -5282,6 +5282,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: '在切片软件中打开', + openInSlicerWith: '用{{slicer}}打开', + moreSlicerOptions: '更多切片软件选项', tabs: { model: '3D 模型', gcode: 'G-code 预览', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index e072903ac..cb7901547 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -5282,6 +5282,8 @@ export default { // Model Viewer modelViewer: { openInSlicer: '在切片軟體中開啟', + openInSlicerWith: '用{{slicer}}開啟', + moreSlicerOptions: '更多切片軟體選項', tabs: { model: '3D 模型', gcode: 'G-code 預覽',