Files
3Dviewer/frontend/src/components/viewer/toolbar/FloatingToolbar.vue
likegears 8daf1a601c Refactor settings panel to compact floating toolbar
Replace the bulky side panel (290-340px wide) with a compact floating toolbar at the bottom center of the viewer, maximizing the model viewing area.

New toolbar components:
- FloatingToolbar.vue: Main container with all controls
- ToolbarButton.vue: Reusable icon button with active state
- ToolbarPopup.vue: Reusable popup with click-outside close
- RenderModeSelector.vue: 3-segment render mode control
- ExplodePopup.vue: Explosion settings popup
- SectionPopup.vue: Cross-section settings popup
- RenderPopup.vue: Material and lighting settings popup

Toolbar features:
- Direct controls: Render mode, edge lines toggle, auto-color toggle
- Popup controls: Explode (click for animation), section, render settings
- Screenshot button with preview modal

Removed old components:
- FeaturePanel.vue, ExplodedView.vue, CrossSection.vue
- RenderSettings.vue, ThumbnailCapture.vue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 17:51:47 +08:00

457 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useViewerStore } from '@/stores/viewer'
import { getRenderService } from '@/services/renderService'
import { getPartsTreeService } from '@/services/partsTreeService'
import { getExplodeService } from '@/services/explodeService'
import { captureFullScreenshot } from '@/services/screenshotService'
import ToolbarButton from './ToolbarButton.vue'
import RenderModeSelector from './RenderModeSelector.vue'
import ExplodePopup from './ExplodePopup.vue'
import SectionPopup from './SectionPopup.vue'
import RenderPopup from './RenderPopup.vue'
const viewerStore = useViewerStore()
// Active popup state
const activePopup = ref<'explode' | 'section' | 'render' | null>(null)
// Screenshot state
const isCapturing = ref(false)
const showPreview = ref(false)
const previewUrl = ref<string | null>(null)
const screenshotBlob = ref<Blob | null>(null)
// Explode animation state
const isExplodeAnimating = ref(false)
// Computed states
const edgesEnabled = computed(() => viewerStore.renderSettings.edgesEnabled)
const autoColorEnabled = computed(() => viewerStore.renderSettings.autoColorEnabled)
const hasActiveCrossSection = computed(() =>
viewerStore.crossSection.x.enabled ||
viewerStore.crossSection.y.enabled ||
viewerStore.crossSection.z.enabled
)
const isExploded = computed(() => viewerStore.explosionFactor > 0)
// Watch edge line toggle
watch(
() => viewerStore.renderSettings.edgesEnabled,
(enabled) => {
const renderService = getRenderService()
if (renderService.isInitialized()) {
renderService.setEdgesEnabled(enabled)
viewerStore.forceRender()
}
}
)
// Watch auto-color changes
watch(
() => viewerStore.renderSettings.autoColorEnabled,
(enabled) => {
const service = getPartsTreeService()
if (enabled) {
service.applyAutoColors()
} else {
service.resetToOriginalColors()
}
viewerStore.forceRender()
}
)
function togglePopup(popup: 'explode' | 'section' | 'render') {
activePopup.value = activePopup.value === popup ? null : popup
}
function closePopup() {
activePopup.value = null
}
function toggleEdges() {
viewerStore.setEdgesEnabled(!edgesEnabled.value)
}
function toggleAutoColor() {
viewerStore.setAutoColorEnabled(!autoColorEnabled.value)
}
function handleExplodeClick() {
// Single click triggers animation
const service = getExplodeService()
if (!service.isInitialized() || service.getPartsCount() < 2) {
togglePopup('explode')
return
}
const currentFactor = viewerStore.explosionFactor
const targetFactor = currentFactor <= 50 ? 100 : 0
isExplodeAnimating.value = true
service.animateExplosion(targetFactor, 800, () => {
viewerStore.setExplosionFactor(targetFactor)
isExplodeAnimating.value = false
})
}
async function captureScreenshot() {
if (!viewerStore.renderer || !viewerStore.scene || !viewerStore.camera) {
return
}
isCapturing.value = true
try {
const blob = await captureFullScreenshot(
viewerStore.renderer,
viewerStore.scene,
viewerStore.camera
)
screenshotBlob.value = blob
previewUrl.value = URL.createObjectURL(blob)
showPreview.value = true
} catch (error) {
console.error('Failed to capture screenshot:', error)
} finally {
isCapturing.value = false
}
}
function downloadScreenshot() {
if (!screenshotBlob.value) return
const now = new Date()
const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14)
const filename = `screenshot_${timestamp}.png`
const url = URL.createObjectURL(screenshotBlob.value)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
closePreview()
}
function closePreview() {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
showPreview.value = false
previewUrl.value = null
screenshotBlob.value = null
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showPreview.value) {
closePreview()
} else if (activePopup.value) {
closePopup()
}
}
}
function handleOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('preview-overlay')) {
closePreview()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
})
</script>
<template>
<div v-if="viewerStore.model" class="floating-toolbar">
<!-- Render Mode Selector -->
<RenderModeSelector />
<div class="toolbar-divider"></div>
<!-- Edge Lines Toggle -->
<ToolbarButton
:active="edgesEnabled"
title="边缘线"
@click="toggleEdges"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</ToolbarButton>
<!-- Auto Color Toggle -->
<ToolbarButton
:active="autoColorEnabled"
title="自动着色"
@click="toggleAutoColor"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
</svg>
</ToolbarButton>
<div class="toolbar-divider"></div>
<!-- Explode -->
<div class="toolbar-item">
<ToolbarButton
:active="isExploded"
:disabled="isExplodeAnimating"
title="爆炸图 (单击动画,右键设置)"
@click="handleExplodeClick"
@contextmenu.prevent="togglePopup('explode')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L8 6H4v4l-4 4 4 4v4h4l4 4 4-4h4v-4l4-4-4-4V6h-4L12 2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</ToolbarButton>
<ExplodePopup
:show="activePopup === 'explode'"
@close="closePopup"
/>
</div>
<!-- Cross Section -->
<div class="toolbar-item">
<ToolbarButton
:active="hasActiveCrossSection"
title="剖面"
@click="togglePopup('section')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="2" x2="12" y2="22"/>
</svg>
</ToolbarButton>
<SectionPopup
:show="activePopup === 'section'"
@close="closePopup"
/>
</div>
<div class="toolbar-divider"></div>
<!-- Render Settings -->
<div class="toolbar-item">
<ToolbarButton
title="渲染设置"
@click="togglePopup('render')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</ToolbarButton>
<RenderPopup
:show="activePopup === 'render'"
@close="closePopup"
/>
</div>
<!-- Screenshot -->
<ToolbarButton
:disabled="isCapturing"
title="截图"
@click="captureScreenshot"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
</ToolbarButton>
</div>
<!-- Screenshot Preview Modal -->
<Teleport to="body">
<div
v-if="showPreview"
class="preview-overlay"
@click="handleOverlayClick"
>
<div class="preview-modal">
<div class="preview-image-container">
<img
v-if="previewUrl"
:src="previewUrl"
alt="Screenshot preview"
class="preview-image"
/>
</div>
<div class="preview-actions">
<button class="btn-download" @click="downloadScreenshot">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
下载
</button>
<button class="btn-cancel" @click="closePreview">
取消
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.floating-toolbar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: var(--panel-overlay-bg);
backdrop-filter: blur(var(--panel-backdrop-blur));
-webkit-backdrop-filter: blur(var(--panel-backdrop-blur));
border-radius: var(--radius-xl);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg);
z-index: 30;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 4px;
}
.toolbar-item {
position: relative;
}
/* Screenshot Preview Styles */
.preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
animation: fade-in 0.2s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.preview-modal {
background: var(--bg-primary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: scale-in 0.2s ease;
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.preview-image-container {
padding: var(--space-4);
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.preview-actions {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
justify-content: center;
border-top: 1px solid var(--border-color);
}
.btn-download,
.btn-cancel {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-download {
background: var(--primary-color);
color: white;
}
.btn-download:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.btn-download .icon {
width: 16px;
height: 16px;
}
.btn-cancel {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.btn-cancel:hover {
background: var(--bg-sunken);
color: var(--text-primary);
}
</style>