mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
Added option to open/slice files in local slicers when slicer api is enabled
This commit is contained in:
@@ -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(
|
||||
<ModelViewerModal
|
||||
libraryFileId={1}
|
||||
title="Model.3mf"
|
||||
fileType="3mf"
|
||||
onClose={mockOnClose}
|
||||
onSliceWithBambuddy={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ModelViewerModal
|
||||
libraryFileId={1}
|
||||
title="Model.3mf"
|
||||
fileType="3mf"
|
||||
onClose={mockOnClose}
|
||||
onSliceWithBambuddy={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ModelViewerModal
|
||||
archiveId={1}
|
||||
title="Test Model"
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ModelViewerModal
|
||||
archiveId={1}
|
||||
title="Test Model"
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ModelViewerModal
|
||||
libraryFileId={1}
|
||||
title="Model.stl"
|
||||
fileType="stl"
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ModelViewerModal
|
||||
libraryFileId={1}
|
||||
title="Model.stl"
|
||||
fileType="stl"
|
||||
onClose={mockOnClose}
|
||||
onSliceWithBambuddy={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="relative inline-flex" ref={containerRef}>
|
||||
<div className="flex relative z-50">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onPrimary();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
disabled={disabled}
|
||||
aria-label={dropdownLabel}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="rounded-l-none border-l border-bambu-dark px-2"
|
||||
>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1 w-56 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-50 py-1"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onClick();
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{useBambuddySlicer ? (
|
||||
<Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
|
||||
<Cog className="w-4 h-4" />
|
||||
{t('slice.action')}
|
||||
</Button>
|
||||
canOpenInSlicer ? (
|
||||
<SlicerSplitButton
|
||||
icon={<Cog className="w-4 h-4" />}
|
||||
label={t('slice.action')}
|
||||
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
||||
onPrimary={() => onSliceWithBambuddy?.()}
|
||||
items={slicerDropdownItems}
|
||||
/>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
|
||||
<Cog className="w-4 h-4" />
|
||||
{t('slice.action')}
|
||||
</Button>
|
||||
)
|
||||
) : canOpenInSlicer ? (
|
||||
<SlicerSplitButton
|
||||
icon={<ExternalLink className="w-4 h-4" />}
|
||||
label={t('modelViewer.openInSlicer')}
|
||||
dropdownLabel={t('modelViewer.moreSlicerOptions')}
|
||||
onPrimary={() => handleOpenInSlicer(preferredSlicer)}
|
||||
items={slicerDropdownItems}
|
||||
/>
|
||||
) : (
|
||||
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleOpenInSlicer(preferredSlicer)} disabled>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('modelViewer.openInSlicer')}
|
||||
</Button>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5294,6 +5294,8 @@ export default {
|
||||
// Model Viewer
|
||||
modelViewer: {
|
||||
openInSlicer: 'スライサーで開く',
|
||||
openInSlicerWith: '{{slicer}}で開く',
|
||||
moreSlicerOptions: 'その他のスライサーオプション',
|
||||
tabs: {
|
||||
model: '3Dモデル',
|
||||
gcode: 'G-codeプレビュー',
|
||||
|
||||
@@ -5029,6 +5029,8 @@ export default {
|
||||
},
|
||||
modelViewer: {
|
||||
openInSlicer: '슬라이서에서 열기',
|
||||
openInSlicerWith: '{{slicer}}에서 열기',
|
||||
moreSlicerOptions: '슬라이서 옵션 더 보기',
|
||||
tabs: {
|
||||
model: '3D 모델',
|
||||
gcode: 'G-code 미리보기'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5017,6 +5017,8 @@ export default {
|
||||
},
|
||||
modelViewer: {
|
||||
openInSlicer: "Открыть в слайсере",
|
||||
openInSlicerWith: "Открыть в {{slicer}}",
|
||||
moreSlicerOptions: "Другие варианты слайсера",
|
||||
tabs: {
|
||||
model: "3D-модель",
|
||||
gcode: "Предпросмотр G-code",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5282,6 +5282,8 @@ export default {
|
||||
// Model Viewer
|
||||
modelViewer: {
|
||||
openInSlicer: '在切片软件中打开',
|
||||
openInSlicerWith: '用{{slicer}}打开',
|
||||
moreSlicerOptions: '更多切片软件选项',
|
||||
tabs: {
|
||||
model: '3D 模型',
|
||||
gcode: 'G-code 预览',
|
||||
|
||||
@@ -5282,6 +5282,8 @@ export default {
|
||||
// Model Viewer
|
||||
modelViewer: {
|
||||
openInSlicer: '在切片軟體中開啟',
|
||||
openInSlicerWith: '用{{slicer}}開啟',
|
||||
moreSlicerOptions: '更多切片軟體選項',
|
||||
tabs: {
|
||||
model: '3D 模型',
|
||||
gcode: 'G-code 預覽',
|
||||
|
||||
Reference in New Issue
Block a user