新增欢迎页
This commit is contained in:
1 parent
a11d57f27f
commit
ab32146e8d
47 files changed
+1080
-527
No files matched your search
+23
-299
@@ -1,308 +1,32 @@
|
||||
<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 />
|
||||
<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 './views/Stage.vue'
|
||||
import ParticlePanel from './views/ParticlePanel.vue'
|
||||
import Timeline from './views/Timeline.vue'
|
||||
import TransferProgressOverlay from './views/TransferProgressOverlay.vue'
|
||||
import { transferProgress } from './editor/transferProgress'
|
||||
import { provideParticlePanel } from './views/particle-panel/particlePanelContext'
|
||||
import { PARTICLE_SECTIONS } from './views/particle-panel/particleSections'
|
||||
import { trackMatomoEvent } from './editor/analytics'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import WelcomePage from './views/WelcomePage.vue'
|
||||
import EditorWorkspace from './views/EditorWorkspace.vue'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
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
|
||||
}
|
||||
const editorActive = ref(window.location.hash === '#/editor')
|
||||
|
||||
function syncViewFromUrl() {
|
||||
editorActive.value = window.location.hash === '#/editor'
|
||||
document.title = i18n.global.t(editorActive.value ? 'app.title' : 'welcome.documentTitle')
|
||||
}
|
||||
function saveTimelineHeight() {
|
||||
if (timelineH.value <= 0) return
|
||||
try { localStorage.setItem(TIMELINE_HEIGHT_KEY, String(timelineH.value)) } catch { /* 缓存不可用时保留当前会话高度。 */ }
|
||||
function openEditor() {
|
||||
if (window.location.hash !== '#/editor') window.location.hash = '/editor'
|
||||
else syncViewFromUrl()
|
||||
}
|
||||
function openWelcome() {
|
||||
if (window.location.hash !== '#/') window.location.hash = '/'
|
||||
else syncViewFromUrl()
|
||||
}
|
||||
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()
|
||||
syncViewFromUrl()
|
||||
window.addEventListener('hashchange', syncViewFromUrl)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
onUnmounted(() => window.removeEventListener('hashchange', syncViewFromUrl))
|
||||
</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>
|
||||
<template>
|
||||
<WelcomePage v-if="!editorActive" @open-editor="openEditor" />
|
||||
<EditorWorkspace v-else @home="openWelcome" />
|
||||
</template>
|
||||
@@ -7,6 +7,38 @@
|
||||
"hudTip": "Radzoom · Klicken Sie mit der linken Maustaste, um die Leinwand zu ziehen",
|
||||
"title": "Spine-Partikeleditor - Yeomai"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Yeomai Spine-Werkzeuge",
|
||||
"titleLead": "Von Spine-Partikeln",
|
||||
"titleAccent": "bis zur Spine-Toolchain",
|
||||
"backToTools": "Zurück zur Werkzeugauswahl",
|
||||
"editor": {
|
||||
"badge": "PARTIKELEDITOR",
|
||||
"title": "Partikeleditor",
|
||||
"description": "Erstelle Spine-Partikel und exportiere JSON- und Spine-Projektdateien frei.",
|
||||
"tagPreview": "Live-Vorschau",
|
||||
"tagSequence": "Spine-Projektexport",
|
||||
"tagExport": "JSON-Export",
|
||||
"cta": "Partikeleditor öffnen"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "EAGLE-PLUGIN",
|
||||
"description": "Prüfe deine Spine-Assets einfach in Eagle.",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "Animationsvorschau",
|
||||
"tagTimeline": "Formatunterstützung",
|
||||
"cta": "SpineViewer ansehen"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "EAGLE-PLUGIN",
|
||||
"auto": "Automatisch erzeugen",
|
||||
"description": "Erkennt Spine-Assets und erzeugt Vorschaubilder – der Partner zu SpineViewer.",
|
||||
"tagPairing": "Asset-Zuordnung",
|
||||
"tagModes": "Statisch / animiert",
|
||||
"tagBatch": "Stapelverarbeitung",
|
||||
"cta": "SpineThumbnails ansehen"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "Schließen",
|
||||
"collapse": "Einklappen",
|
||||
|
||||
@@ -19,6 +19,38 @@
|
||||
"ptBR": "Português",
|
||||
"es": "Español"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Yeomai Spine Tools",
|
||||
"titleLead": "From Spine particles",
|
||||
"titleAccent": "to the Spine toolchain",
|
||||
"backToTools": "Back to tools",
|
||||
"editor": {
|
||||
"badge": "PARTICLE EDITOR",
|
||||
"title": "Particle Editor",
|
||||
"description": "Build Spine particle systems and freely export JSON and Spine project files.",
|
||||
"tagPreview": "Live preview",
|
||||
"tagSequence": "Spine project export",
|
||||
"tagExport": "JSON export",
|
||||
"cta": "Open Particle Editor"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "EAGLE PLUGIN",
|
||||
"description": "Easily review your Spine assets inside Eagle.",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "Animation preview",
|
||||
"tagTimeline": "Format support",
|
||||
"cta": "View SpineViewer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "EAGLE PLUGIN",
|
||||
"auto": "Auto generate",
|
||||
"description": "Detect Spine assets and generate thumbnails, a companion tool for SpineViewer.",
|
||||
"tagPairing": "Asset pairing",
|
||||
"tagModes": "Static / animated",
|
||||
"tagBatch": "Batch output",
|
||||
"cta": "View SpineThumbnails"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"collapse": "Collapse",
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
"hudTip": "Zoom de rueda · Clic izquierdo para arrastrar el lienzo",
|
||||
"title": "Editor de partículas Spine - Yeomai"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Herramientas Spine de Yeomai",
|
||||
"titleLead": "De las partículas Spine",
|
||||
"titleAccent": "a las herramientas Spine",
|
||||
"backToTools": "Volver a las herramientas",
|
||||
"editor": {
|
||||
"badge": "EDITOR DE PARTÍCULAS",
|
||||
"title": "Editor de partículas",
|
||||
"description": "Crea partículas Spine y exporta libremente archivos JSON y proyectos Spine.",
|
||||
"tagPreview": "Vista previa",
|
||||
"tagSequence": "Exportar proyecto Spine",
|
||||
"tagExport": "Exportar JSON",
|
||||
"cta": "Abrir editor de partículas"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "PLUGIN DE EAGLE",
|
||||
"description": "Consulta tus recursos Spine fácilmente en Eagle.",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "Vista previa de animación",
|
||||
"tagTimeline": "Formatos compatibles",
|
||||
"cta": "Ver SpineViewer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "PLUGIN DE EAGLE",
|
||||
"auto": "Generar automáticamente",
|
||||
"description": "Detecta recursos Spine y genera miniaturas, una herramienta complementaria a SpineViewer.",
|
||||
"tagPairing": "Asociación automática",
|
||||
"tagModes": "Estática / animada",
|
||||
"tagBatch": "Proceso por lotes",
|
||||
"cta": "Ver SpineThumbnails"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "Cerrar",
|
||||
"collapse": "Contraer",
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
"hudTip": "Zoom sur la molette · Clic gauche pour faire glisser la toile",
|
||||
"title": "Éditeur de particules Spine - Yeomai"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Outils Spine Yeomai",
|
||||
"titleLead": "Des particules Spine",
|
||||
"titleAccent": "à la chaîne d’outils Spine",
|
||||
"backToTools": "Retour aux outils",
|
||||
"editor": {
|
||||
"badge": "ÉDITEUR",
|
||||
"title": "Éditeur de particules",
|
||||
"description": "Créez des particules Spine et exportez librement les fichiers JSON et projets Spine.",
|
||||
"tagPreview": "Aperçu direct",
|
||||
"tagSequence": "Export du projet Spine",
|
||||
"tagExport": "Export JSON",
|
||||
"cta": "Ouvrir l’éditeur"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "PLUGIN EAGLE",
|
||||
"description": "Consultez facilement vos ressources Spine dans Eagle.",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "Aperçu animé",
|
||||
"tagTimeline": "Formats compatibles",
|
||||
"cta": "Voir SpineViewer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "PLUGIN EAGLE",
|
||||
"auto": "Génération auto",
|
||||
"description": "Détecte les ressources Spine et génère des miniatures, en complément de SpineViewer.",
|
||||
"tagPairing": "Association auto",
|
||||
"tagModes": "Statique / animé",
|
||||
"tagBatch": "Traitement par lot",
|
||||
"cta": "Voir SpineThumbnails"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "Fermer",
|
||||
"collapse": "Réduire",
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
"hudTip": "ホイールズーム・左クリックでキャンバスをドラッグ",
|
||||
"title": "Spineパーティクルエディター-Yeomai"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Yeomai Spine ツール",
|
||||
"titleLead": "Spineパーティクルから",
|
||||
"titleAccent": "Spine ツールチェーンへ",
|
||||
"backToTools": "ツール一覧へ戻る",
|
||||
"editor": {
|
||||
"badge": "PARTICLE EDITOR",
|
||||
"title": "パーティクルエディター",
|
||||
"description": "Spine パーティクルを作成し、JSON と Spine プロジェクトを自由に出力できます。",
|
||||
"tagPreview": "リアルタイム表示",
|
||||
"tagSequence": "Spine プロジェクト出力",
|
||||
"tagExport": "JSON 出力",
|
||||
"cta": "エディターを開く"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "EAGLE プラグイン",
|
||||
"description": "Eagle で Spine アセットを手軽に確認できます。",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "アニメーション表示",
|
||||
"tagTimeline": "形式サポート",
|
||||
"cta": "SpineViewer を見る"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "EAGLE プラグイン",
|
||||
"auto": "自動生成",
|
||||
"description": "Spine アセットを自動認識してサムネイルを生成。SpineViewer のパートナーツールです。",
|
||||
"tagPairing": "自動ペアリング",
|
||||
"tagModes": "静止 / 動画",
|
||||
"tagBatch": "一括生成",
|
||||
"cta": "SpineThumbnails を見る"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "閉じる",
|
||||
"collapse": "折りたたむ",
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
"hudTip": "휠 확대/축소 · 캔버스를 드래그하려면 왼쪽 클릭",
|
||||
"title": "Spine 파티클 에디터-Yeomai"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Yeomai Spine 도구",
|
||||
"titleLead": "Spine 파티클 시스템에서",
|
||||
"titleAccent": "Spine 도구까지",
|
||||
"backToTools": "도구 목록으로 돌아가기",
|
||||
"editor": {
|
||||
"badge": "PARTICLE EDITOR",
|
||||
"title": "파티클 에디터",
|
||||
"description": "Spine 파티클 시스템을 만들고 JSON과 Spine 프로젝트 파일을 자유롭게 내보냅니다.",
|
||||
"tagPreview": "실시간 미리보기",
|
||||
"tagSequence": "Spine 프로젝트 내보내기",
|
||||
"tagExport": "JSON 내보내기",
|
||||
"cta": "파티클 에디터 열기"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "EAGLE 플러그인",
|
||||
"description": "Eagle에서 Spine 에셋을 간편하게 확인합니다.",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "애니메이션 미리보기",
|
||||
"tagTimeline": "형식 지원",
|
||||
"cta": "SpineViewer 보기"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "EAGLE 플러그인",
|
||||
"auto": "자동 생성",
|
||||
"description": "Spine 에셋을 자동 감지해 썸네일을 생성합니다. SpineViewer 파트너 도구입니다.",
|
||||
"tagPairing": "자동 연결",
|
||||
"tagModes": "정적 / 동적",
|
||||
"tagBatch": "일괄 생성",
|
||||
"cta": "SpineThumbnails 보기"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "닫기",
|
||||
"collapse": "접기",
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
"hudTip": "Zoom da roda · Clique com o botão esquerdo para arrastar a tela",
|
||||
"title": "Editor de Partículas Spine - Yeomai"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Ferramentas Spine Yeomai",
|
||||
"titleLead": "Das partículas Spine",
|
||||
"titleAccent": "à cadeia de ferramentas Spine",
|
||||
"backToTools": "Voltar às ferramentas",
|
||||
"editor": {
|
||||
"badge": "EDITOR DE PARTÍCULAS",
|
||||
"title": "Editor de partículas",
|
||||
"description": "Crie partículas Spine e exporte livremente arquivos JSON e projetos Spine.",
|
||||
"tagPreview": "Prévia ao vivo",
|
||||
"tagSequence": "Exportar projeto Spine",
|
||||
"tagExport": "Exportar JSON",
|
||||
"cta": "Abrir editor de partículas"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "PLUGIN EAGLE",
|
||||
"description": "Visualize seus recursos Spine com facilidade no Eagle.",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "Prévia da animação",
|
||||
"tagTimeline": "Formatos suportados",
|
||||
"cta": "Ver SpineViewer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "PLUGIN EAGLE",
|
||||
"auto": "Gerar automaticamente",
|
||||
"description": "Detecta recursos Spine e gera miniaturas, uma ferramenta parceira do SpineViewer.",
|
||||
"tagPairing": "Associação automática",
|
||||
"tagModes": "Estática / animada",
|
||||
"tagBatch": "Processamento em lote",
|
||||
"cta": "Ver SpineThumbnails"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "Fechar",
|
||||
"collapse": "Recolher",
|
||||
|
||||
@@ -19,6 +19,38 @@
|
||||
"ptBR": "Português",
|
||||
"es": "Español"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Yeomai Spine 工具集",
|
||||
"titleLead": "从Spine粒子系统",
|
||||
"titleAccent": "延伸到 Spine 工具链",
|
||||
"backToTools": "返回工具首页",
|
||||
"editor": {
|
||||
"badge": "粒子编辑",
|
||||
"title": "粒子编辑器",
|
||||
"description": "Spine粒子系统,免费使用自由导出JSON与Spine工程文件。",
|
||||
"tagPreview": "实时预览",
|
||||
"tagSequence": "Spine工程导出",
|
||||
"tagExport": "JSON 导出",
|
||||
"cta": "进入粒子编辑器"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "EAGLE 插件",
|
||||
"description": "在 Eagle 中轻松查看你的Spine资产。",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "动画预览",
|
||||
"tagTimeline": "格式扩展",
|
||||
"cta": "查看 SpineViewer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "EAGLE 插件",
|
||||
"auto": "自动生成",
|
||||
"description": "自动识别 Spine 素材并生成缩略图,SpineViewer伙伴工具",
|
||||
"tagPairing": "自动配对",
|
||||
"tagModes": "静态 / 动态",
|
||||
"tagBatch": "批量生成",
|
||||
"cta": "查看 SpineThumbnails"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "关闭",
|
||||
"collapse": "收起",
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
"hudTip": "滾輪縮放 · 左鍵拖動畫布",
|
||||
"title": "Spine粒子編輯器-Yeomai"
|
||||
},
|
||||
"welcome": {
|
||||
"documentTitle": "Yeomai Spine 工具集",
|
||||
"titleLead": "從Spine粒子系統",
|
||||
"titleAccent": "延伸到 Spine 工具鏈",
|
||||
"backToTools": "返回工具首頁",
|
||||
"editor": {
|
||||
"badge": "粒子編輯",
|
||||
"title": "粒子編輯器",
|
||||
"description": "Spine粒子系統,可自由匯出JSON與Spine專案檔案。",
|
||||
"tagPreview": "即時預覽",
|
||||
"tagSequence": "Spine專案匯出",
|
||||
"tagExport": "JSON 匯出",
|
||||
"cta": "進入粒子編輯器"
|
||||
},
|
||||
"viewer": {
|
||||
"badge": "EAGLE 外掛",
|
||||
"description": "在 Eagle 中輕鬆查看你的Spine資產。",
|
||||
"tagVersions": "3.4–4.3",
|
||||
"tagSkins": "動畫預覽",
|
||||
"tagTimeline": "格式擴充",
|
||||
"cta": "查看 SpineViewer"
|
||||
},
|
||||
"thumbnail": {
|
||||
"badge": "EAGLE 外掛",
|
||||
"auto": "自動產生",
|
||||
"description": "自動辨識 Spine 素材並產生縮圖,SpineViewer 夥伴工具",
|
||||
"tagPairing": "自動配對",
|
||||
"tagModes": "靜態 / 動態",
|
||||
"tagBatch": "批次產生",
|
||||
"cta": "查看 SpineThumbnails"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"close": "關閉",
|
||||
"collapse": "收合",
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { setAppLocale, SUPPORTED_LOCALES, type AppLocale } from '../i18n'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
menuPlacement?: 'up' | 'down'
|
||||
}>(), {
|
||||
menuPlacement: 'up',
|
||||
})
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const showMenu = ref(false)
|
||||
const picker = ref<HTMLElement | null>(null)
|
||||
const trigger = ref<HTMLButtonElement | null>(null)
|
||||
const options = ref<HTMLButtonElement[]>([])
|
||||
const currentLocale = computed(() => SUPPORTED_LOCALES.find((entry) => entry.code === locale.value) ?? SUPPORTED_LOCALES[0])
|
||||
const flagSpriteUrl = `${import.meta.env.BASE_URL}images/flags.svg`
|
||||
|
||||
function focusOption(index: number) {
|
||||
options.value[index]?.focus()
|
||||
}
|
||||
function openMenu() {
|
||||
showMenu.value = true
|
||||
nextTick(() => {
|
||||
const index = SUPPORTED_LOCALES.findIndex((entry) => entry.code === locale.value)
|
||||
focusOption(Math.max(0, index))
|
||||
})
|
||||
}
|
||||
function closeMenu(refocus = false) {
|
||||
showMenu.value = false
|
||||
if (refocus) nextTick(() => trigger.value?.focus())
|
||||
}
|
||||
function toggleMenu() {
|
||||
if (showMenu.value) closeMenu()
|
||||
else openMenu()
|
||||
}
|
||||
function selectLocale(next: AppLocale) {
|
||||
setAppLocale(next)
|
||||
closeMenu(true)
|
||||
}
|
||||
function onTriggerKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
event.preventDefault()
|
||||
openMenu()
|
||||
}
|
||||
function onOptionKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeMenu(true)
|
||||
return
|
||||
}
|
||||
let nextIndex = index
|
||||
if (event.key === 'ArrowDown') nextIndex = (index + 1) % SUPPORTED_LOCALES.length
|
||||
else if (event.key === 'ArrowUp') nextIndex = (index - 1 + SUPPORTED_LOCALES.length) % SUPPORTED_LOCALES.length
|
||||
else if (event.key === 'Home') nextIndex = 0
|
||||
else if (event.key === 'End') nextIndex = SUPPORTED_LOCALES.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
focusOption(nextIndex)
|
||||
}
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (showMenu.value && !picker.value?.contains(event.target as Node)) closeMenu()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('pointerdown', onDocumentPointerDown))
|
||||
onUnmounted(() => document.removeEventListener('pointerdown', onDocumentPointerDown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="picker" class="language-picker" :class="{ 'menu-down': menuPlacement === 'down' }" @keydown.esc.prevent="closeMenu(true)">
|
||||
<span>{{ t('language.label') }}</span>
|
||||
<div class="language-dropdown">
|
||||
<button
|
||||
ref="trigger"
|
||||
type="button"
|
||||
class="language-trigger"
|
||||
:class="{ open: showMenu }"
|
||||
:aria-label="t('language.label')"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="showMenu"
|
||||
@click="toggleMenu"
|
||||
@keydown.down.prevent="onTriggerKeydown"
|
||||
@keydown.up.prevent="onTriggerKeydown"
|
||||
>
|
||||
<svg class="language-flag-icon" viewBox="0 0 24 16" aria-hidden="true">
|
||||
<use :href="`${flagSpriteUrl}#${currentLocale.flag}`" />
|
||||
</svg>
|
||||
<span class="language-short">{{ currentLocale.shortLabel }}</span>
|
||||
<svg class="language-chevron" viewBox="0 0 10 6" aria-hidden="true">
|
||||
<path d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="showMenu"
|
||||
class="language-menu"
|
||||
role="listbox"
|
||||
:aria-label="t('language.label')"
|
||||
>
|
||||
<button
|
||||
v-for="(entry, index) in SUPPORTED_LOCALES"
|
||||
:key="entry.code"
|
||||
ref="options"
|
||||
type="button"
|
||||
class="language-option"
|
||||
:class="{ selected: entry.code === locale }"
|
||||
role="option"
|
||||
:aria-selected="entry.code === locale"
|
||||
@click="selectLocale(entry.code)"
|
||||
@keydown="onOptionKeydown($event, index)"
|
||||
>
|
||||
<svg class="language-option-flag-icon" viewBox="0 0 24 16" aria-hidden="true">
|
||||
<use :href="`${flagSpriteUrl}#${entry.flag}`" />
|
||||
</svg>
|
||||
<span>{{ entry.label }}</span>
|
||||
<span v-if="entry.code === locale" class="language-check" aria-hidden="true">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.language-picker {
|
||||
display: flex; height: 28px; align-items: center; gap: 6px; margin-left: auto;
|
||||
color: #9aa8c1; font-size: 11px; white-space: nowrap;
|
||||
}
|
||||
.language-dropdown { position: relative; }
|
||||
.language-trigger {
|
||||
display: flex; height: 28px; min-width: 88px; align-items: center; gap: 7px; padding: 0 8px;
|
||||
border: 1px solid #3a4963; border-radius: 6px; outline: none;
|
||||
background: #202a3b; color: #dbe4f5; font: inherit; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.language-trigger:hover, .language-trigger.open { border-color: #5b608c; background: #2b3a5c; }
|
||||
.language-trigger:focus-visible { border-color: #7774ff; }
|
||||
.language-flag-icon, .language-option-flag-icon {
|
||||
display: block; width: 22px; height: 15px; flex: 0 0 auto; overflow: hidden;
|
||||
border-radius: 2px; box-shadow: 0 0 0 1px rgba(255,255,255,.12);
|
||||
}
|
||||
.language-short { min-width: 20px; color: #dbe4f5; font-size: 11px; font-weight: 600; }
|
||||
.language-chevron { width: 10px; height: 6px; margin-left: auto; fill: none; stroke: #aab; stroke-width: 1.7; transition: transform .15s ease; }
|
||||
.language-trigger.open .language-chevron { transform: rotate(180deg); }
|
||||
.language-menu {
|
||||
position: absolute; right: 0; bottom: calc(100% + 6px); z-index: 30;
|
||||
width: 190px; max-height: min(360px, calc(100vh - 120px)); overflow-y: auto;
|
||||
padding: 5px; border: 1px solid #343b50; border-radius: 9px;
|
||||
background: rgba(20,20,32,.98); box-shadow: 0 8px 24px rgba(0,0,0,.48);
|
||||
}
|
||||
.menu-down .language-menu { top: calc(100% + 6px); bottom: auto; }
|
||||
.language-option {
|
||||
display: grid; width: 100%; min-height: 38px; grid-template-columns: 24px 1fr 16px; align-items: center;
|
||||
gap: 8px; padding: 0 7px; border: 0; border-radius: 6px;
|
||||
background: transparent; color: #aeb8cc; font: inherit; font-size: 12px; text-align: left; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.language-option:hover, .language-option:focus-visible { outline: none; background: #2b3a5c; color: #fff; }
|
||||
.language-option.selected { background: #343b63; color: #fff; }
|
||||
.language-check { justify-self: end; color: #fff; font-size: 18px; font-weight: 500; }
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@
|
||||
@update:active-tool="activeTool = $event"
|
||||
@update:show-settings="showSettings = $event"
|
||||
@update:show-help="showHelp = $event"
|
||||
@home="emit('home')"
|
||||
@clear-background="clearBackground"
|
||||
@background-file="onBackgroundFileChange"
|
||||
/>
|
||||
@@ -60,6 +61,7 @@ import { sampleScenePath } from '../core/scenePathSampler'
|
||||
import { followCurveValue } from './stage/stageGeometry'
|
||||
|
||||
const store = useParticleStore()
|
||||
const emit = defineEmits<{ (e: 'home'): void }>()
|
||||
const holder = ref<HTMLElement | null>(null)
|
||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||
const showSettings = ref(false)
|
||||
|
||||
+10
-141
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { zoomIn, zoomOut, fitView, resetView, toggleGrid, type StageContext } from './stage/viewControls'
|
||||
import { undoLastOperation, editorHistoryState } from '../editor/editorHistory'
|
||||
import { setAppLocale, SUPPORTED_LOCALES, type AppLocale } from '../i18n'
|
||||
import LanguagePicker from './LanguagePicker.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
ctx: StageContext
|
||||
@@ -18,21 +18,16 @@ const emit = defineEmits<{
|
||||
(e: 'update:activeTool', v: '' | 'translate' | 'rotate' | 'scale'): void
|
||||
(e: 'update:showSettings', v: boolean): void
|
||||
(e: 'update:showHelp', v: boolean): void
|
||||
(e: 'home'): void
|
||||
(e: 'clear-background', resetControls: boolean): void
|
||||
(e: 'background-file', event: Event): void
|
||||
}>()
|
||||
|
||||
const store = useParticleStore()
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const showBackgroundMenu = ref(false)
|
||||
const backgroundFileInput = ref<HTMLInputElement | null>(null)
|
||||
const backgroundFileName = computed(() => store.settings.backgroundImageName)
|
||||
const showLanguageMenu = ref(false)
|
||||
const languagePicker = ref<HTMLElement | null>(null)
|
||||
const languageTrigger = ref<HTMLButtonElement | null>(null)
|
||||
const languageOptions = ref<HTMLButtonElement[]>([])
|
||||
const currentLocale = computed(() => SUPPORTED_LOCALES.find((entry) => entry.code === locale.value) ?? SUPPORTED_LOCALES[0])
|
||||
const flagSpriteUrl = `${import.meta.env.BASE_URL}images/flags.svg`
|
||||
|
||||
function toggleTool(t: '' | 'translate' | 'rotate' | 'scale') {
|
||||
emit('update:activeTool', props.activeTool === t ? '' : t)
|
||||
@@ -67,60 +62,12 @@ function undo() {
|
||||
undoLastOperation()
|
||||
}
|
||||
|
||||
function focusLanguageOption(index: number) {
|
||||
const options = languageOptions.value
|
||||
options[index]?.focus()
|
||||
}
|
||||
function openLanguageMenu() {
|
||||
showLanguageMenu.value = true
|
||||
nextTick(() => {
|
||||
const index = SUPPORTED_LOCALES.findIndex((entry) => entry.code === locale.value)
|
||||
focusLanguageOption(Math.max(0, index))
|
||||
})
|
||||
}
|
||||
function closeLanguageMenu(refocus = false) {
|
||||
showLanguageMenu.value = false
|
||||
if (refocus) nextTick(() => languageTrigger.value?.focus())
|
||||
}
|
||||
function toggleLanguageMenu() {
|
||||
if (showLanguageMenu.value) closeLanguageMenu()
|
||||
else openLanguageMenu()
|
||||
}
|
||||
function selectLanguage(next: AppLocale) {
|
||||
setAppLocale(next)
|
||||
closeLanguageMenu(true)
|
||||
}
|
||||
function onLanguageTriggerKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
event.preventDefault()
|
||||
openLanguageMenu()
|
||||
}
|
||||
function onLanguageOptionKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeLanguageMenu(true)
|
||||
return
|
||||
}
|
||||
let nextIndex = index
|
||||
if (event.key === 'ArrowDown') nextIndex = (index + 1) % SUPPORTED_LOCALES.length
|
||||
else if (event.key === 'ArrowUp') nextIndex = (index - 1 + SUPPORTED_LOCALES.length) % SUPPORTED_LOCALES.length
|
||||
else if (event.key === 'Home') nextIndex = 0
|
||||
else if (event.key === 'End') nextIndex = SUPPORTED_LOCALES.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
focusLanguageOption(nextIndex)
|
||||
}
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (showLanguageMenu.value && !languagePicker.value?.contains(event.target as Node)) closeLanguageMenu()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('pointerdown', onDocumentPointerDown))
|
||||
onUnmounted(() => document.removeEventListener('pointerdown', onDocumentPointerDown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="top-operation-stack" data-matomo-section="toolbar">
|
||||
<div class="topbar">
|
||||
<button class="home-btn" @click="emit('home')" :title="t('welcome.backToTools')">⌂</button>
|
||||
<button class="settings-btn" @click="toggleSettings" :title="t('toolbar.settings')">{{ t('toolbar.settings') }}</button>
|
||||
<button class="undo-btn" :disabled="editorHistoryState.undoCount === 0" @click="undo" :title="t('toolbar.undo')">↶</button>
|
||||
|
||||
@@ -173,57 +120,7 @@ onUnmounted(() => document.removeEventListener('pointerdown', onDocumentPointerD
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="languagePicker" class="language-picker" @keydown.esc.prevent="closeLanguageMenu(true)">
|
||||
<span>{{ t('language.label') }}</span>
|
||||
<div class="language-dropdown">
|
||||
<button
|
||||
ref="languageTrigger"
|
||||
type="button"
|
||||
class="language-trigger"
|
||||
:class="{ open: showLanguageMenu }"
|
||||
:aria-label="t('language.label')"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="showLanguageMenu"
|
||||
@click="toggleLanguageMenu"
|
||||
@keydown.down.prevent="onLanguageTriggerKeydown"
|
||||
@keydown.up.prevent="onLanguageTriggerKeydown"
|
||||
>
|
||||
<svg class="language-flag-icon" viewBox="0 0 24 16" aria-hidden="true">
|
||||
<use :href="`${flagSpriteUrl}#${currentLocale.flag}`" />
|
||||
</svg>
|
||||
<span class="language-short">{{ currentLocale.shortLabel }}</span>
|
||||
<svg class="language-chevron" viewBox="0 0 10 6" aria-hidden="true">
|
||||
<path d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="showLanguageMenu"
|
||||
id="language-menu"
|
||||
class="language-menu"
|
||||
role="listbox"
|
||||
:aria-label="t('language.label')"
|
||||
>
|
||||
<button
|
||||
v-for="(entry, index) in SUPPORTED_LOCALES"
|
||||
:key="entry.code"
|
||||
ref="languageOptions"
|
||||
type="button"
|
||||
class="language-option"
|
||||
:class="{ selected: entry.code === locale }"
|
||||
role="option"
|
||||
:aria-selected="entry.code === locale"
|
||||
@click="selectLanguage(entry.code)"
|
||||
@keydown="onLanguageOptionKeydown($event, index)"
|
||||
>
|
||||
<svg class="language-option-flag-icon" viewBox="0 0 24 16" aria-hidden="true">
|
||||
<use :href="`${flagSpriteUrl}#${entry.flag}`" />
|
||||
</svg>
|
||||
<span>{{ entry.label }}</span>
|
||||
<span v-if="entry.code === locale" class="language-check" aria-hidden="true">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LanguagePicker />
|
||||
<button class="help-btn" :class="{ on: showHelp }" :title="t('toolbar.openHelp')" @click="openHelp">{{ t('toolbar.help') }}</button>
|
||||
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||
</div>
|
||||
@@ -312,39 +209,11 @@ b { color: #ffcc33; }
|
||||
.background-opacity-row { display: grid; grid-template-columns: 46px 1fr 34px; align-items: center; gap: 7px; margin-top: 10px; font-size: 11px; }
|
||||
.background-opacity-row input { min-width: 0; accent-color: #675de0; }
|
||||
.background-opacity-row b { color: #aeb8cc; font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.language-picker {
|
||||
display: flex; height: 28px; align-items: center; gap: 6px; margin-left: auto;
|
||||
color: #9aa8c1; font-size: 11px; white-space: nowrap;
|
||||
.home-btn {
|
||||
width: 30px; height: 28px; flex: 0 0 30px; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #aeb9cf; font-size: 15px; cursor: pointer;
|
||||
}
|
||||
.language-dropdown { position: relative; }
|
||||
.language-trigger {
|
||||
display: flex; height: 28px; min-width: 88px; align-items: center; gap: 7px; padding: 0 8px;
|
||||
border: 1px solid #3a4963; border-radius: 6px; outline: none;
|
||||
background: #202a3b; color: #dbe4f5; font: inherit; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.language-trigger:hover, .language-trigger.open { border-color: #5b608c; background: #2b3a5c; }
|
||||
.language-trigger:focus-visible { border-color: #7774ff; }
|
||||
.language-flag-icon, .language-option-flag-icon {
|
||||
display: block; width: 22px; height: 15px; flex: 0 0 auto; overflow: hidden;
|
||||
border-radius: 2px; box-shadow: 0 0 0 1px rgba(255,255,255,.12);
|
||||
}
|
||||
.language-short { min-width: 20px; color: #dbe4f5; font-size: 11px; font-weight: 600; }
|
||||
.language-chevron { width: 10px; height: 6px; margin-left: auto; fill: none; stroke: #aab; stroke-width: 1.7; transition: transform .15s ease; }
|
||||
.language-trigger.open .language-chevron { transform: rotate(180deg); }
|
||||
.language-menu {
|
||||
position: absolute; right: 0; bottom: calc(100% + 6px); z-index: 30;
|
||||
width: 190px; max-height: min(360px, calc(100vh - 120px)); overflow-y: auto;
|
||||
padding: 5px; border: 1px solid #343b50; border-radius: 9px;
|
||||
background: rgba(20,20,32,.98); box-shadow: 0 8px 24px rgba(0,0,0,.48);
|
||||
}
|
||||
.language-option {
|
||||
display: grid; width: 100%; min-height: 38px; grid-template-columns: 24px 1fr 16px; align-items: center;
|
||||
gap: 8px; padding: 0 7px; border: 0; border-radius: 6px;
|
||||
background: transparent; color: #aeb8cc; font: inherit; font-size: 12px; text-align: left; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.language-option:hover, .language-option:focus-visible { outline: none; background: #2b3a5c; color: #fff; }
|
||||
.language-option.selected { background: #343b63; color: #fff; }
|
||||
.language-check { justify-self: end; color: #fff; font-size: 18px; font-weight: 500; }
|
||||
.home-btn:hover { border-color: #7774ff; background: #2b3a5c; color: #fff; }
|
||||
.help-btn {
|
||||
height: 28px; padding: 0 11px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #cbd7ed; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import LanguagePicker from './LanguagePicker.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'open-editor'): void }>()
|
||||
const { t, locale } = useI18n()
|
||||
const promotionLocale = computed(() => {
|
||||
if (locale.value === 'zh-CN') return 'cn'
|
||||
if (locale.value === 'zh-TW') return 'hk'
|
||||
if (locale.value === 'ja') return 'ja'
|
||||
return 'en'
|
||||
})
|
||||
const particlePromotion = computed(() => `${import.meta.env.BASE_URL}images/SpineParticles/${promotionLocale.value}.png`)
|
||||
const viewerPromotion = computed(() => `${import.meta.env.BASE_URL}images/SpineViewer/${promotionLocale.value}.png`)
|
||||
const thumbnailPromotion = computed(() => `${import.meta.env.BASE_URL}images/SpineThumbnail/${promotionLocale.value}.png`)
|
||||
|
||||
watch(locale, () => {
|
||||
document.title = t('welcome.documentTitle')
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="welcome-shell" data-matomo-section="welcome">
|
||||
<header class="welcome-header">
|
||||
<a
|
||||
class="brand-link"
|
||||
href="https://www.yeomai.site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Yeomai"
|
||||
data-matomo-event="welcome.open-yeomai"
|
||||
>
|
||||
<img class="brand-logo" src="/images/yeomai-watermark.png" alt="Yeomai" />
|
||||
</a>
|
||||
<LanguagePicker menu-placement="down" />
|
||||
</header>
|
||||
|
||||
<section class="welcome-intro">
|
||||
<h1>{{ t('welcome.titleLead') }} <span>{{ t('welcome.titleAccent') }}</span></h1>
|
||||
</section>
|
||||
|
||||
<section class="hub-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="hub-card editor-card"
|
||||
data-matomo-event="welcome.open-editor"
|
||||
@click="emit('open-editor')"
|
||||
>
|
||||
<div class="hub-preview editor-preview" aria-hidden="true">
|
||||
<img class="promotion-image" :src="particlePromotion" alt="" />
|
||||
<span class="preview-badge badge-with-icon">
|
||||
<img src="/favicon.png" alt="" aria-hidden="true" />
|
||||
{{ t('welcome.editor.badge') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>{{ t('welcome.editor.title') }}</h2>
|
||||
<p>{{ t('welcome.editor.description') }}</p>
|
||||
<div class="tag-row">
|
||||
<span>{{ t('welcome.editor.tagPreview') }}</span>
|
||||
<span>{{ t('welcome.editor.tagSequence') }}</span>
|
||||
<span>{{ t('welcome.editor.tagExport') }}</span>
|
||||
</div>
|
||||
<span class="card-cta">{{ t('welcome.editor.cta') }} <b>→</b></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="hub-card viewer-card"
|
||||
href="https://spineviewer.yeomai.site/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-matomo-event="welcome.open-viewer"
|
||||
>
|
||||
<div class="hub-preview viewer-preview" aria-hidden="true">
|
||||
<img class="promotion-image" :src="viewerPromotion" alt="" />
|
||||
<span class="preview-badge badge-with-icon">
|
||||
<img src="/images/eagle-logo.png" alt="" aria-hidden="true" />
|
||||
{{ t('welcome.viewer.badge') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>SpineViewer</h2>
|
||||
<p>{{ t('welcome.viewer.description') }}</p>
|
||||
<div class="tag-row">
|
||||
<span>{{ t('welcome.viewer.tagVersions') }}</span>
|
||||
<span>{{ t('welcome.viewer.tagSkins') }}</span>
|
||||
<span>{{ t('welcome.viewer.tagTimeline') }}</span>
|
||||
</div>
|
||||
<span class="card-cta">{{ t('welcome.viewer.cta') }} <b>→</b></span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="hub-card thumbnail-card"
|
||||
href="https://spinethumbnail.yeomai.site/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-matomo-event="welcome.open-thumbnail"
|
||||
>
|
||||
<div class="hub-preview thumbnail-preview" aria-hidden="true">
|
||||
<img class="promotion-image" :src="thumbnailPromotion" alt="" />
|
||||
<span class="preview-badge badge-with-icon">
|
||||
<img src="/images/eagle-logo.png" alt="" aria-hidden="true" />
|
||||
{{ t('welcome.thumbnail.badge') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>SpineThumbnails</h2>
|
||||
<p>{{ t('welcome.thumbnail.description') }}</p>
|
||||
<div class="tag-row">
|
||||
<span>{{ t('welcome.thumbnail.tagPairing') }}</span>
|
||||
<span>{{ t('welcome.thumbnail.tagModes') }}</span>
|
||||
<span>{{ t('welcome.thumbnail.tagBatch') }}</span>
|
||||
</div>
|
||||
<span class="card-cta">{{ t('welcome.thumbnail.cta') }} <b>→</b></span>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.welcome-shell {
|
||||
position: fixed; inset: 0; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
|
||||
padding: clamp(22px, 3vw, 42px) clamp(22px, 4vw, 64px) 28px;
|
||||
color: #eef0f5;
|
||||
background:
|
||||
radial-gradient(circle at 17% 0%, rgba(218, 45, 57, .16), transparent 32%),
|
||||
radial-gradient(circle at 85% 8%, rgba(72, 100, 170, .16), transparent 30%),
|
||||
linear-gradient(145deg, #09090d 0%, #0d0f15 52%, #090b10 100%);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.welcome-shell::before {
|
||||
position: fixed; inset: 0; pointer-events: none; content: ""; opacity: .22;
|
||||
background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(to bottom, #000, transparent 70%);
|
||||
}
|
||||
.welcome-header, .welcome-intro, .hub-grid { position: relative; }
|
||||
.welcome-header { display: flex; min-height: 42px; align-items: center; gap: 18px; }
|
||||
.welcome-header { z-index: 40; }
|
||||
.brand-link { display: inline-flex; border-radius: 6px; transition: opacity .16s ease; }
|
||||
.brand-link:hover { opacity: .78; }
|
||||
.brand-link:focus-visible { outline: 2px solid #ef5147; outline-offset: 5px; }
|
||||
.brand-logo { display: block; width: clamp(128px, 10vw, 156px); height: auto; }
|
||||
.welcome-intro { padding: clamp(34px, 6vh, 64px) 0 clamp(22px, 3.5vh, 34px); text-align: center; }
|
||||
.welcome-intro h1 { max-width: 980px; margin: 0 auto; color: #f3f3f5; font-size: clamp(31px, 3.2vw, 48px); line-height: 1.1; letter-spacing: -.025em; }
|
||||
.welcome-intro h1 span { color: #ff4e47; }
|
||||
.welcome-intro p { max-width: 390px; margin: 0 0 3px; color: #8c93a3; font-size: 13px; line-height: 1.75; }
|
||||
.hub-grid {
|
||||
display: grid; min-height: 0; flex: 0 1 auto;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(14px, 1.5vw, 22px);
|
||||
width: min(100%, 1540px); margin: 0 auto;
|
||||
}
|
||||
.hub-card {
|
||||
position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column;
|
||||
padding: 0; overflow: hidden; border: 1px solid #242731; border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(26,29,38,.94), rgba(15,17,23,.98));
|
||||
color: inherit; font: inherit; text-align: left; text-decoration: none; cursor: pointer;
|
||||
box-shadow: 0 20px 52px rgba(0,0,0,.25);
|
||||
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.hub-card:hover { transform: translateY(-3px); border-color: #4a4650; box-shadow: 0 25px 64px rgba(0,0,0,.38); }
|
||||
.hub-card:focus-visible { outline: 2px solid #ff5b50; outline-offset: 2px; }
|
||||
.editor-card:hover { border-color: #706bea; }
|
||||
.viewer-card:hover, .thumbnail-card:hover { border-color: #f04d45; }
|
||||
.hub-preview { position: relative; width: 100%; aspect-ratio: 3 / 2; flex: 0 0 auto; overflow: hidden; border-bottom: 1px solid #252832; }
|
||||
.preview-badge {
|
||||
position: absolute; top: 14px; left: 14px; z-index: 4; padding: 5px 9px;
|
||||
border: 1px solid rgba(255,255,255,.11); border-radius: 999px;
|
||||
background: rgba(10,11,16,.72); color: #c7cbd5; font-size: 9px; letter-spacing: .08em;
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
.badge-with-icon { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.badge-with-icon img { display: block; width: 16px; height: 16px; border-radius: 4px; }
|
||||
.card-body { display: flex; flex: 1 1 auto; flex-direction: column; padding: clamp(16px, 1.7vw, 24px) clamp(16px, 1.8vw, 25px) clamp(18px, 2vw, 27px); }
|
||||
.card-body h2 { margin: 0 0 8px; color: #f1f2f5; font-size: clamp(22px, 1.8vw, 28px); line-height: 1.1; letter-spacing: -.02em; }
|
||||
.card-body p { min-height: 40px; margin: 0; color: #858c9c; font-size: 12px; line-height: 1.65; }
|
||||
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 18px; }
|
||||
.tag-row span { padding: 4px 8px; border: 1px solid #34394a; border-radius: 999px; background: #1a1e2a; color: #8f98ad; font-size: 9px; }
|
||||
.editor-card .tag-row span { border-color: rgba(119,116,255,.35); background: rgba(74,72,150,.16); color: #aaa7f5; }
|
||||
.viewer-card .tag-row span, .thumbnail-card .tag-row span { border-color: rgba(238,76,68,.24); background: rgba(111,36,37,.14); color: #d99a97; }
|
||||
.card-cta { display: flex; align-items: center; justify-content: space-between; margin-top: auto; color: #f2f3f6; font-size: 12px; font-weight: 700; }
|
||||
.card-cta b { color: #ff5a51; font-size: 18px; font-weight: 500; }
|
||||
.viewer-preview, .thumbnail-preview { background: #101218; }
|
||||
.promotion-image { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; }
|
||||
@media (max-width: 1180px) {
|
||||
.welcome-shell { overflow-y: auto; }
|
||||
.hub-grid { flex: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.welcome-shell { padding: 18px 16px 24px; }
|
||||
.welcome-header :deep(.language-picker > span) { display: none; }
|
||||
.welcome-intro { padding: 24px 0 18px; }
|
||||
.hub-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hub-card { transition: none; }
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user