资源整理
This commit is contained in:
1 parent
7062a60c73
commit
5efee6d918
37 files changed
+265
-29
No files matched your search
+9
-9
@@ -10,15 +10,15 @@ import zhCN from './locales/zh-CN.json'
|
||||
import zhTW from './locales/zh-TW.json'
|
||||
|
||||
export const SUPPORTED_LOCALES = [
|
||||
{ code: 'zh-CN', label: '中文' },
|
||||
{ code: 'zh-TW', label: '繁體中文' },
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'pt-BR', label: 'Português' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'zh-CN', label: '中文', flag: 'cn', shortLabel: '中' },
|
||||
{ code: 'zh-TW', label: '繁體中文', flag: 'cn', shortLabel: '繁' },
|
||||
{ code: 'en', label: 'English', flag: 'us', shortLabel: 'EN' },
|
||||
{ code: 'ja', label: '日本語', flag: 'jp', shortLabel: 'JA' },
|
||||
{ code: 'ko', label: '한국어', flag: 'kr', shortLabel: 'KO' },
|
||||
{ code: 'fr', label: 'Français', flag: 'fr', shortLabel: 'FR' },
|
||||
{ code: 'de', label: 'Deutsch', flag: 'de', shortLabel: 'DE' },
|
||||
{ code: 'pt-BR', label: 'Português (Brasil)', flag: 'br', shortLabel: 'PT' },
|
||||
{ code: 'es', label: 'Español', flag: 'es', shortLabel: 'ES' },
|
||||
] as const
|
||||
|
||||
export type AppLocale = typeof SUPPORTED_LOCALES[number]['code']
|
||||
|
||||
+133
-13
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { zoomIn, zoomOut, fitView, resetView, toggleGrid, type StageContext } from './stage/viewControls'
|
||||
@@ -27,6 +27,12 @@ const { t, locale } = 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)
|
||||
@@ -61,10 +67,55 @@ function undo() {
|
||||
undoLastOperation()
|
||||
}
|
||||
|
||||
function changeLanguage(event: Event) {
|
||||
const next = (event.target as HTMLSelectElement).value as AppLocale
|
||||
setAppLocale(next)
|
||||
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>
|
||||
@@ -122,12 +173,57 @@ function changeLanguage(event: Event) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="language-picker">
|
||||
<div ref="languagePicker" class="language-picker" @keydown.esc.prevent="closeLanguageMenu(true)">
|
||||
<span>{{ t('language.label') }}</span>
|
||||
<select :value="locale" :aria-label="t('language.label')" @change="changeLanguage">
|
||||
<option v-for="entry in SUPPORTED_LOCALES" :key="entry.code" :value="entry.code">{{ entry.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
<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>
|
||||
@@ -220,11 +316,35 @@ b { color: #ffcc33; }
|
||||
display: flex; height: 28px; align-items: center; gap: 6px; margin-left: auto;
|
||||
color: #9aa8c1; font-size: 11px; white-space: nowrap;
|
||||
}
|
||||
.language-picker select {
|
||||
height: 28px; padding: 0 24px 0 8px; border: 1px solid #3a4963; border-radius: 6px;
|
||||
outline: none; background: #202a3b; color: #dbe4f5; font-size: 11px; 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-picker select:focus { border-color: #7774ff; }
|
||||
.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; }
|
||||
.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;
|
||||
|
||||
Reference in new issue
Block a user