Files
SpineParticlesWeb/src/views/EditorWorkspace.vue
T
2026-11-07 15:12:13 +08:00

312 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="layout"
:class="{ 'panel-right': store.settings.parameterPanelOnRight }"
:inert="transferProgress.visible ? true : undefined"
:aria-hidden="transferProgress.visible ? 'true' : undefined"
>
<!-- 参数面板:可在系统设置中切换左右位置,宽度可拖。 -->
<div v-show="showParameterPanel" class="parameter-panel" :style="{ width: panelW + 'px' }">
<ParticlePanel />
</div>
<div v-show="showParameterPanel" class="resizer-col" @pointerdown="onPanelDrag"></div>
<!-- 右列:上=画布区,下=时间轴区(高度可拖) -->
<div class="right">
<div
class="stage-area"
:class="{ 'timeline-collapsed': timelineCollapsed, 'operation-bar-clipped': operationBarHeight !== null }"
:style="stageStyle"
>
<Stage @home="$emit('home')" />
<nav
v-if="store.settings.floatingMenuEnabled"
class="floating-section-nav"
:class="{ 'nav-left': !store.settings.parameterPanelOnRight }"
role="tablist"
:aria-label="$t('settings.floatingMenu')"
>
<button
v-for="(section, index) in PARTICLE_SECTIONS"
:key="section.key"
type="button"
role="tab"
class="floating-section-tab"
:class="{ selected: activeSection === section.key && canUseSection(section.key) }"
:disabled="!canUseSection(section.key)"
:aria-selected="activeSection === section.key && canUseSection(section.key)"
:title="canUseSection(section.key) ? $t(section.titleKey) : $t('settings.floatingMenuParticleOnly')"
:data-matomo-event="`navigation.${section.key}`"
@click="activateSection(section.key)"
>
<span class="floating-section-index">{{ index + 1 }}</span>
<span class="floating-section-label">{{ $t(section.titleKey) }}</span>
</button>
</nav>
<button
type="button"
class="timeline-collapse-toggle"
:class="{ collapsed: !bottomPanelExpanded }"
:aria-expanded="bottomPanelExpanded"
:title="timelineCollapsed ? '展开时间轴' : '点击收起,拖动调整高度'"
data-matomo-event="timeline.toggle"
@pointerdown="onTimelineTogglePointerDown"
@click="toggleTimeline"
>
<svg class="timeline-collapse-shape" viewBox="0 0 160 34" preserveAspectRatio="none" aria-hidden="true">
<path class="timeline-collapse-fill" d="M0 34 C 24 34 35 32 48 25 C 60 19 68 4 80 4 C 92 4 100 19 112 25 C 125 32 136 34 160 34 Z" />
<path class="timeline-collapse-outline" vector-effect="non-scaling-stroke" d="M0 34 C 24 34 35 32 48 25 C 60 19 68 4 80 4 C 92 4 100 19 112 25 C 125 32 136 34 160 34" />
</svg>
<span class="timeline-collapse-lines"><i></i><i></i><i></i></span>
</button>
</div>
<div v-show="!timelineCollapsed && timelineH > 0" class="resizer-row" @pointerdown="onTimelineDrag" :title="$t('app.resizeTimeline')"></div>
<div v-show="!timelineCollapsed && timelineH > 0" class="timeline-area" :style="{ height: timelineH + 'px' }">
<Timeline />
</div>
</div>
</div>
<AnnouncementBar />
<TransferProgressOverlay />
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useParticleStore } from '../store/particleStore'
import { installSystemSettingsCache } from '../editor/systemSettings'
import Stage from './Stage.vue'
import ParticlePanel from './ParticlePanel.vue'
import Timeline from './Timeline.vue'
import TransferProgressOverlay from './TransferProgressOverlay.vue'
import AnnouncementBar from './AnnouncementBar.vue'
import { transferProgress } from '../editor/transferProgress'
import { provideParticlePanel } from './particle-panel/particlePanelContext'
import { PARTICLE_SECTIONS } from './particle-panel/particleSections'
import { trackMatomoEvent } from '../editor/analytics'
defineEmits<{ (e: 'home'): void }>()
const store = useParticleStore()
installSystemSettingsCache(store)
const { activeSection, activateSection, canUseSection } = provideParticlePanel()
const showParameterPanel = computed(() => !store.settings.floatingMenuEnabled || activeSection.value !== null)
const panelW = ref(360)
const TIMELINE_HEIGHT_KEY = 'SpineParticle.TimelineHeight.v1'
function loadTimelineHeight() {
try {
const height = Number(localStorage.getItem(TIMELINE_HEIGHT_KEY))
if (!Number.isFinite(height) || height <= 0) return 180
return Math.min(height, window.innerHeight - 200)
} catch {
return 180
}
}
function saveTimelineHeight() {
if (timelineH.value <= 0) return
try { localStorage.setItem(TIMELINE_HEIGHT_KEY, String(timelineH.value)) } catch { /* 缓存不可用时保留当前会话高度。 */ }
}
const timelineH = ref(loadTimelineHeight())
const timelineCollapsed = ref(false)
const operationBarHeight = ref<number | null>(null)
const renderedOperationBarHeight = ref(84)
let lastExpandedTimelineHeight = timelineH.value
let fullOperationBarHeight = 84
let operationBarResizeObserver: ResizeObserver | null = null
const stageStyle = computed<Record<string, string>>(() => ({
'--operation-bar-rendered-height': `${renderedOperationBarHeight.value}px`,
...(operationBarHeight.value == null ? {} : { '--operation-bar-height': `${operationBarHeight.value}px` }),
}))
const bottomPanelExpanded = computed(() =>
!timelineCollapsed.value
&& (timelineH.value > 0 || operationBarHeight.value == null || operationBarHeight.value > 0),
)
let ignoreTimelineToggleClick = false
onMounted(() => {
const operationBar = document.querySelector<HTMLElement>('.top-operation-stack')
if (!operationBar) return
const updateHeight = () => {
const height = operationBar.getBoundingClientRect().height
if (height <= 0) return
renderedOperationBarHeight.value = height
if (operationBarHeight.value == null && !timelineCollapsed.value) fullOperationBarHeight = height
}
operationBarResizeObserver = new ResizeObserver(updateHeight)
operationBarResizeObserver.observe(operationBar)
updateHeight()
})
onUnmounted(() => {
operationBarResizeObserver?.disconnect()
operationBarResizeObserver = null
})
function toggleTimeline() {
if (ignoreTimelineToggleClick) return
if (!bottomPanelExpanded.value) {
operationBarHeight.value = null
timelineH.value = lastExpandedTimelineHeight
timelineCollapsed.value = false
return
}
const operationBar = document.querySelector<HTMLElement>('.top-operation-stack')
if (operationBar) fullOperationBarHeight = Math.max(operationBar.getBoundingClientRect().height, operationBar.scrollHeight)
if (timelineH.value > 0) lastExpandedTimelineHeight = timelineH.value
saveTimelineHeight()
operationBarHeight.value = null
timelineCollapsed.value = true
}
function onTimelineTogglePointerDown(e: PointerEvent) {
if (e.button !== 0) return
const startY = e.clientY
const operationBar = document.querySelector<HTMLElement>('.top-operation-stack')
const wasCollapsed = timelineCollapsed.value
if (!wasCollapsed && timelineH.value > 0) lastExpandedTimelineHeight = timelineH.value
const baseOperationHeight = wasCollapsed
? fullOperationBarHeight
: operationBar
? Math.max(operationBar.getBoundingClientRect().height, operationBar.scrollHeight)
: fullOperationBarHeight
fullOperationBarHeight = baseOperationHeight
const startAdjustment = wasCollapsed
? -baseOperationHeight
: timelineH.value + (operationBarHeight.value ?? baseOperationHeight) - baseOperationHeight
let dragging = false
const move = (event: PointerEvent) => {
const deltaY = startY - event.clientY
if (!dragging && Math.abs(deltaY) < 4) return
dragging = true
const nextAdjustment = Math.min(startAdjustment + deltaY, window.innerHeight - 200)
if (nextAdjustment <= 0) {
timelineCollapsed.value = false
timelineH.value = 0
operationBarHeight.value = Math.max(0, baseOperationHeight + nextAdjustment)
} else {
timelineCollapsed.value = false
timelineH.value = nextAdjustment
operationBarHeight.value = null
}
event.preventDefault()
}
const finish = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', finish)
window.removeEventListener('pointercancel', finish)
if (!dragging) return
trackMatomoEvent('时间轴', '调整数值', `时间轴高度=${timelineH.value}`)
if (timelineH.value > 0) saveTimelineHeight()
// 拖拽后的 click 与本次手势属于同一操作,不能再次切换折叠。
ignoreTimelineToggleClick = true
setTimeout(() => { ignoreTimelineToggleClick = false })
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', finish)
window.addEventListener('pointercancel', finish)
}
// 参数面板宽度拖拽:面板在右侧时,从视口右边缘反向计算宽度。
function onPanelDrag(e: PointerEvent) {
e.preventDefault()
const move = (ev: PointerEvent) => {
const pointerWidth = store.settings.parameterPanelOnRight
? window.innerWidth - ev.clientX
: ev.clientX
const w = Math.max(240, Math.min(pointerWidth, window.innerWidth - 200))
panelW.value = w
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// 时间轴高度拖拽
function onTimelineDrag(e: PointerEvent) {
e.preventDefault()
const startY = e.clientY
const startH = timelineH.value
const move = (ev: PointerEvent) => {
const h = Math.max(80, Math.min(startH + (startY - ev.clientY), window.innerHeight - 200))
timelineH.value = h
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
saveTimelineHeight()
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
</script>
<style scoped>
.layout { display: flex; width: 100%; height: 100%; min-height: 0; overflow: clip; }
.parameter-panel { flex-shrink: 0; height: 100%; min-height: 0; overflow: clip; }
.resizer-col { width: 5px; cursor: col-resize; background: #1c1c2a; border-left: 1px solid #2a2a3c; border-right: 1px solid #2a2a3c; flex-shrink: 0; }
.resizer-col:hover { background: #3a5a8c; }
.right { flex: 1; min-width: 0; height: 100%; min-height: 0; overflow: clip; display: flex; flex-direction: column; }
.panel-right .right { order: 1; }
.panel-right .resizer-col { order: 2; }
.panel-right .parameter-panel { order: 3; }
.stage-area { position: relative; flex: 1; min-height: 0; overflow: clip; }
.floating-section-nav {
position: absolute; right: 12px; top: 12px; z-index: 6; display: flex;
width: clamp(184px, 15vw, 236px); max-height: calc(100% - 124px);
flex-direction: column; gap: 5px; overflow-y: auto; box-sizing: border-box; padding: 8px;
border: 1px solid rgba(79, 91, 121, .72); border-radius: 10px;
background: rgba(15, 18, 29, .9); box-shadow: 0 12px 30px rgba(0, 0, 0, .36);
scrollbar-width: thin; scrollbar-color: #3a455e transparent;
}
.floating-section-nav.nav-left { left: 12px; right: auto; }
.floating-section-tab {
display: flex; width: 100%; min-height: 40px; flex: 0 0 auto; align-items: center; gap: 9px;
padding: 0 10px; border: 1px solid #2d3548; border-radius: 8px;
background: #191e2c; color: #aeb9cf; font: inherit; font-size: 12px; text-align: left; cursor: pointer;
}
.floating-section-tab:hover:not(:disabled) { border-color: rgba(112, 121, 167, .45); background: #242b40; color: #f0f3fb; }
.floating-section-tab.selected {
border-color: #6f69db; background: #37365e; color: #f5f4ff;
box-shadow: inset 3px 0 #8580ff;
}
.floating-section-tab:focus-visible { outline: 2px solid #8b86ff; outline-offset: 1px; }
.floating-section-tab:disabled { color: #596476; cursor: not-allowed; opacity: .58; }
.floating-section-index {
display: grid; width: 20px; height: 20px; flex: 0 0 20px; place-items: center;
border-radius: 5px; background: #252d40; color: #8390ad; font-size: 10px; font-variant-numeric: tabular-nums;
}
.floating-section-tab.selected .floating-section-index { background: #7771e8; color: #fff; }
.floating-section-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stage-area.timeline-collapsed :deep(.top-operation-stack) { display: none; }
.stage-area.operation-bar-clipped :deep(.top-operation-stack) {
height: var(--operation-bar-height);
padding-top: min(8px, calc(var(--operation-bar-height) * .1));
padding-bottom: min(8px, calc(var(--operation-bar-height) * .1));
overflow: hidden;
box-sizing: border-box;
}
.timeline-collapse-toggle {
position: absolute; left: 50%; bottom: calc(var(--operation-bar-rendered-height, 84px) - 1px); z-index: 19;
width: 96px; height: 20.4px; padding: 0; transform: translateX(-50%);
border: 0; background: transparent; color: #8792a6; cursor: ns-resize;
touch-action: none; user-select: none;
}
.timeline-collapse-toggle.collapsed { bottom: 0; }
.timeline-collapse-shape {
position: absolute; inset: 0; width: 100%; height: 100%;
fill: none;
}
.timeline-collapse-fill { fill: #12121c; }
.timeline-collapse-outline { fill: none; stroke: #2a2a3c; stroke-width: .75; }
.timeline-collapse-lines {
position: absolute; top: 7px; left: 50%; display: grid;
gap: 2px; transform: translateX(-50%);
}
.timeline-collapse-lines i { display: block; width: 11px; height: 1px; border-radius: 1px; background: currentColor; }
.timeline-collapse-toggle:hover { color: #b3bdce; }
.timeline-collapse-toggle:focus-visible { outline: 2px solid #8b86ff; outline-offset: -2px; }
.resizer-row { height: 4px; cursor: row-resize; background: #1c1c2a; border-top: 1px solid #2a2a3c; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
.resizer-row:hover { background: #3a5a8c; }
.timeline-area { position: relative; z-index: 10; flex-shrink: 0; overflow: visible; }
</style>