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>
This commit is contained in:
456
frontend/src/components/viewer/toolbar/FloatingToolbar.vue
Normal file
456
frontend/src/components/viewer/toolbar/FloatingToolbar.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user