Finish i18n pass for menus and tool labels

Localize remaining menu state labels like Hide/Show and Lock/Unlock,
wire toolbar tool/action labels through the i18n stores, and localize
the app menu UI toggle tooltip.

Replace template-string JSON imports with an explicit locale loader map
so published package builds have stable locale asset references.
This commit is contained in:
Danila Poyarkov 2026-03-25 17:39:47 +03:00
parent eb2891233a
commit ea4676d991
12 changed files with 133 additions and 35 deletions

View file

@ -124,8 +124,8 @@ export function useMenuModel() {
})
const selectionLabelMenu = computed(() => ({
visibility: (editor.getSelectedNode()?.visible ?? true) ? 'Hide' : 'Show',
lock: (editor.getSelectedNode()?.locked ?? false) ? 'Unlock' : 'Lock'
visibility: (editor.getSelectedNode()?.visible ?? true) ? t.value.hide : t.value.show,
lock: (editor.getSelectedNode()?.locked ?? false) ? t.value.unlock : t.value.lock
}))
return {

View file

@ -5,10 +5,20 @@ import { locale } from './locale'
import type { Locale } from './locale'
import type { ComponentsJSON } from '@nanostores/i18n'
const localeLoaders: Record<Exclude<Locale, 'en'>, () => Promise<{ default: ComponentsJSON }>> = {
de: () => import('../locales/de.json'),
es: () => import('../locales/es.json'),
fr: () => import('../locales/fr.json'),
it: () => import('../locales/it.json'),
pl: () => import('../locales/pl.json'),
ru: () => import('../locales/ru.json')
}
export const i18n = createI18n<Locale, 'en'>(locale, {
baseLocale: 'en',
async get(code) {
const mod = (await import(`../locales/${code}.json`)) as { default: ComponentsJSON }
if (code === 'en') return {}
const mod = await localeLoaders[code]()
return mod.default
}
})

View file

@ -26,6 +26,14 @@ export const menuMessages = i18n('menu', {
moveToPage: 'Move to page',
createInstance: 'Create instance',
hide: 'Hide',
show: 'Show',
lock: 'Lock',
unlock: 'Unlock',
cut: 'Cut',
front: 'Front',
back: 'Back',
toggleUi: 'Toggle UI',
bold: 'Bold',
italic: 'Italic',

View file

@ -19,6 +19,14 @@
"profiler": "Leistungsprofil",
"moveToPage": "Auf Seite verschieben",
"createInstance": "Instanz erstellen",
"hide": "Ausblenden",
"show": "Einblenden",
"lock": "Sperren",
"unlock": "Entsperren",
"cut": "Ausschneiden",
"front": "Nach vorn",
"back": "Nach hinten",
"toggleUi": "UI umschalten",
"bold": "Fett",
"italic": "Kursiv",
"underline": "Unterstrichen"

View file

@ -19,6 +19,14 @@
"profiler": "Perfil de rendimiento",
"moveToPage": "Mover a página",
"createInstance": "Crear instancia",
"hide": "Ocultar",
"show": "Mostrar",
"lock": "Bloquear",
"unlock": "Desbloquear",
"cut": "Cortar",
"front": "Al frente",
"back": "Al fondo",
"toggleUi": "Alternar interfaz",
"bold": "Negrita",
"italic": "Cursiva",
"underline": "Subrayado"

View file

@ -19,6 +19,14 @@
"profiler": "Profileur de performance",
"moveToPage": "Déplacer vers la page",
"createInstance": "Créer une instance",
"hide": "Masquer",
"show": "Afficher",
"lock": "Verrouiller",
"unlock": "Déverrouiller",
"cut": "Couper",
"front": "Premier plan",
"back": "Arrière-plan",
"toggleUi": "Basculer l’interface",
"bold": "Gras",
"italic": "Italique",
"underline": "Souligné"

View file

@ -19,6 +19,14 @@
"profiler": "Profilo prestazioni",
"moveToPage": "Sposta nella pagina",
"createInstance": "Crea istanza",
"hide": "Nascondi",
"show": "Mostra",
"lock": "Blocca",
"unlock": "Sblocca",
"cut": "Taglia",
"front": "Davanti",
"back": "Dietro",
"toggleUi": "Mostra/nascondi interfaccia",
"bold": "Grassetto",
"italic": "Corsivo",
"underline": "Sottolineato"

View file

@ -19,6 +19,14 @@
"profiler": "Profiler wydajności",
"moveToPage": "Przenieś na stronę",
"createInstance": "Utwórz instancję",
"hide": "Ukryj",
"show": "Pokaż",
"lock": "Zablokuj",
"unlock": "Odblokuj",
"cut": "Wytnij",
"front": "Do przodu",
"back": "Do tyłu",
"toggleUi": "Przełącz interfejs",
"bold": "Pogrubienie",
"italic": "Kursywa",
"underline": "Podkreślenie"

View file

@ -19,6 +19,14 @@
"profiler": "Профилирование",
"moveToPage": "Переместить на страницу",
"createInstance": "Создать экземпляр",
"hide": "Скрыть",
"show": "Показать",
"lock": "Заблокировать",
"unlock": "Разблокировать",
"cut": "Вырезать",
"front": "Вперёд",
"back": "Назад",
"toggleUi": "Переключить интерфейс",
"bold": "Жирный",
"italic": "Курсив",
"underline": "Подчёркнутый"

View file

@ -18,7 +18,7 @@ import IconChevronRight from '~icons/lucide/chevron-right'
import { computed } from 'vue'
import { useInlineRename } from '@open-pencil/vue'
import { useI18n, useInlineRename } from '@open-pencil/vue'
import { useMenuUI } from '@/components/ui/menu'
import { IS_TAURI } from '@/constants'
import { useAppMenu } from '@/composables/use-app-menu'
@ -46,6 +46,7 @@ function commitRename(input: HTMLInputElement) {
const isMac = navigator.platform.includes('Mac')
const mod = isMac ? '⌘' : 'Ctrl+'
const { menu: t } = useI18n()
interface MenuAction {
separator?: false
@ -91,7 +92,7 @@ const subMenuCls = useMenuUI({ content: 'min-w-44' })
@dblclick="startRename"
>{{ store.state.documentName }}</span
>
<Tip label="Toggle UI (⌘\)">
<Tip :label="`${t.toggleUi} (${mod}\\)`">
<button
data-test-id="app-toggle-ui"
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"

View file

@ -1,7 +1,13 @@
<script setup lang="ts">
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import { LayerTreeRoot, LayerTreeItem, useInlineRename, useLayerDrag } from '@open-pencil/vue'
import {
LayerTreeRoot,
LayerTreeItem,
useI18n,
useInlineRename,
useLayerDrag
} from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
import { nodeIcon, COMPONENT_TYPES } from '@/utils/layer-icons'
import CanvasMenu from './CanvasMenu.vue'
@ -10,6 +16,7 @@ import Tip from './ui/Tip.vue'
const INDENT = 16
const store = useEditorStore()
const rename = useInlineRename((id, name) => store.renameNode(id, name))
const { menu: t } = useI18n()
const { draggingId, instruction, instructionTargetId } = useLayerDrag(store, INDENT)
function onLayerRightClick(e: MouseEvent) {
@ -142,7 +149,7 @@ function onLayerRightClick(e: MouseEvent) {
!node.locked && node.visible ? 'opacity-0 group-hover/row:opacity-100' : ''
"
>
<Tip :label="node.locked ? 'Unlock' : 'Lock'">
<Tip :label="node.locked ? t.unlock : t.lock">
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@ -160,7 +167,7 @@ function onLayerRightClick(e: MouseEvent) {
/>
</span>
</Tip>
<Tip :label="node.visible ? 'Hide' : 'Show'">
<Tip :label="node.visible ? t.hide : t.show">
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop

View file

@ -31,9 +31,12 @@ import {
ToolbarRoot,
ToolbarItem,
useEditorCommands,
useI18n,
useToolbarState,
useViewportKind
} from '@open-pencil/vue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type { Tool } from '@open-pencil/vue'
@ -41,20 +44,21 @@ const store = useEditorStore()
const { isMobile } = useViewportKind()
const { getCommand } = useEditorCommands()
const { showActionToast } = useActionToast()
const { menu: t, tools: toolTexts } = useI18n()
const toolLabels: Record<Tool, string> = {
SELECT: 'Move',
FRAME: 'Frame',
SECTION: 'Section',
RECTANGLE: 'Rectangle',
ELLIPSE: 'Ellipse',
LINE: 'Line',
POLYGON: 'Polygon',
STAR: 'Star',
PEN: 'Pen',
TEXT: 'Text',
HAND: 'Hand'
}
const toolLabels = computed<Record<Tool, string>>(() => ({
SELECT: toolTexts.value.move,
FRAME: toolTexts.value.frame,
SECTION: toolTexts.value.section,
RECTANGLE: toolTexts.value.rectangle,
ELLIPSE: toolTexts.value.ellipse,
LINE: toolTexts.value.line,
POLYGON: toolTexts.value.polygon,
STAR: toolTexts.value.star,
PEN: toolTexts.value.pen,
TEXT: toolTexts.value.text,
HAND: toolTexts.value.hand
}))
const toolShortcuts: Record<Tool, string> = {
SELECT: 'V',
@ -78,29 +82,49 @@ interface ActionItem {
const flyoutMenuCls = useMenuUI({ content: 'min-w-32' })
const editActions: ActionItem[] = [
{ icon: IconCopy, label: 'Copy', action: () => store.mobileCopy() },
{ icon: IconClipboard, label: 'Paste', action: () => store.mobilePaste() },
{ icon: IconScissors, label: 'Cut', action: () => store.mobileCut() },
{ icon: IconCopyPlus, label: 'Duplicate', action: () => getCommand('selection.duplicate').run() },
{ icon: IconTrash2, label: 'Delete', action: () => getCommand('selection.delete').run() }
]
const editActions = computed<ActionItem[]>(() => [
{ icon: IconCopy, label: t.value.copy, action: () => store.mobileCopy() },
{ icon: IconClipboard, label: t.value.paste, action: () => store.mobilePaste() },
{ icon: IconScissors, label: t.value.cut, action: () => store.mobileCut() },
{
icon: IconCopyPlus,
label: getCommand('selection.duplicate').label,
action: () => getCommand('selection.duplicate').run()
},
{
icon: IconTrash2,
label: getCommand('selection.delete').label,
action: () => getCommand('selection.delete').run()
}
])
const arrangeActions: ActionItem[] = [
const arrangeActions = computed<ActionItem[]>(() => [
{
icon: IconArrowUpToLine,
label: 'Front',
label: t.value.front,
action: () => getCommand('selection.bringToFront').run()
},
{
icon: IconArrowDownToLine,
label: 'Back',
label: t.value.back,
action: () => getCommand('selection.sendToBack').run()
},
{ icon: IconGroup, label: 'Group', action: () => getCommand('selection.group').run() },
{ icon: IconUngroup, label: 'Ungroup', action: () => getCommand('selection.ungroup').run() },
{ icon: IconLock, label: 'Lock', action: () => getCommand('selection.toggleLock').run() }
]
{
icon: IconGroup,
label: getCommand('selection.group').label,
action: () => getCommand('selection.group').run()
},
{
icon: IconUngroup,
label: getCommand('selection.ungroup').label,
action: () => getCommand('selection.ungroup').run()
},
{
icon: IconLock,
label: t.value.lock,
action: () => getCommand('selection.toggleLock').run()
}
])
const {
mobileCategory,