新增欢迎页
This commit is contained in:
1 parent
a11d57f27f
commit
ab32146e8d
47 files changed
+1080
-527
No files matched your search
@@ -0,0 +1,309 @@
|
||||
<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>
|
||||
<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 { 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: 21;
|
||||
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>
|
||||
Reference in new issue
Block a user