新增欢迎页

This commit is contained in:
tianmo committed 2026-10-31 11:48:29 +08:00
1 parent a11d57f27f
commit ab32146e8d
47 files changed
+1080 -527

No files matched your search

+23 -299
View File
@@ -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>
+32
View File
@@ -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",
+32
View File
@@ -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",
+32
View File
@@ -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",
+32
View File
@@ -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",
+32
View File
@@ -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": "折りたたむ",
+32
View File
@@ -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": "접기",
+32
View File
@@ -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",
+32
View File
@@ -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": "收起",
+32
View File
@@ -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": "收合",
+309
View File
@@ -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>
+159
View File
@@ -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>
+2
View File
@@ -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
View File
@@ -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;
+202
View File
@@ -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>