From 2b3219e10676b249cf8302a828a3dced08431e86 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 25 Mar 2026 16:17:03 +0300 Subject: [PATCH 01/14] Add i18n to Vue SDK with nanostores nanostores + @nanostores/i18n + @nanostores/vue (~6 KB total). English base translations defined inline in messages.ts, other locales loaded on demand from JSON. Grouped by domain: menu, commands, tools, panels, pages, dialogs. useI18n() composable returns reactive translation objects plus locale controls. Russian locale included as first translation. Browser language auto-detected, user preference persisted in localStorage. --- bun.lock | 9 ++ packages/core/src/fig-export.ts | 9 +- packages/core/src/kiwi/kiwi-serialize.ts | 30 +++--- packages/core/src/scene-graph.ts | 7 +- packages/vue/package.json | 5 +- packages/vue/src/i18n/i18n.ts | 14 +++ packages/vue/src/i18n/index.ts | 11 ++ packages/vue/src/i18n/locale.ts | 23 ++++ packages/vue/src/i18n/messages.ts | 130 +++++++++++++++++++++++ packages/vue/src/i18n/useI18n.ts | 46 ++++++++ packages/vue/src/index.ts | 13 +++ packages/vue/src/locales/ru.json | 112 +++++++++++++++++++ packages/vue/tsconfig.json | 3 +- src/composables/use-keyboard.ts | 2 +- 14 files changed, 392 insertions(+), 22 deletions(-) create mode 100644 packages/vue/src/i18n/i18n.ts create mode 100644 packages/vue/src/i18n/index.ts create mode 100644 packages/vue/src/i18n/locale.ts create mode 100644 packages/vue/src/i18n/messages.ts create mode 100644 packages/vue/src/i18n/useI18n.ts create mode 100644 packages/vue/src/locales/ru.json diff --git a/bun.lock b/bun.lock index 5f50da6e8..54dbf83e0 100644 --- a/bun.lock +++ b/bun.lock @@ -153,7 +153,10 @@ "name": "@open-pencil/vue", "version": "0.10.0", "dependencies": { + "@nanostores/i18n": "^1.2.2", + "@nanostores/vue": "^1.1.0", "@vueuse/core": "^14.2.1", + "nanostores": "^1.2.0", }, "peerDependencies": { "@open-pencil/core": "workspace:*", @@ -676,6 +679,10 @@ "@multiformats/multiaddr-to-uri": ["@multiformats/multiaddr-to-uri@11.0.2", "", { "dependencies": { "@multiformats/multiaddr": "^12.3.0" } }, "sha512-SiLFD54zeOJ0qMgo9xv1Tl9O5YktDKAVDP4q4hL16mSq4O4sfFNagNADz8eAofxd6TfQUzGQ3TkRRG9IY2uHRg=="], + "@nanostores/i18n": ["@nanostores/i18n@1.2.2", "", { "peerDependencies": { "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0" } }, "sha512-5LLxl95+ZI46MrM/Kn7YjORKsD7+Xy2tgjZ7/oDT/BGPEiaBM9lK89/afeK+BqaQL0Xd9Xaa5MPuuVSyWAo+/w=="], + + "@nanostores/vue": ["@nanostores/vue@1.1.0", "", { "peerDependencies": { "@nanostores/logger": "^0.4.0 || ^1.0.0", "@vue/devtools-api": ">=7.6.2", "nanostores": "^0.11.3 || ^1.0.0", "vue": ">=3.3.1" }, "optionalPeers": ["@nanostores/logger", "@vue/devtools-api"] }, "sha512-14eY8/YA0AbtWkAjk8zHKxbjcuPubF+M7y9afQzg2b5VqRJcashwA0100vprau13BTBSynUmU6ZsU6x03YidjA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], @@ -1976,6 +1983,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanostores": ["nanostores@1.2.0", "", {}, "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index 97e29b681..094e9643e 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -304,6 +304,13 @@ export function compressFigData( return compressViaWorker(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries) } return Promise.resolve( - compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries, figKiwiVersion) + compressFigDataSync( + schemaDeflated, + kiwiData, + thumbnailPng, + metaJson, + imageEntries, + figKiwiVersion + ) ) } diff --git a/packages/core/src/kiwi/kiwi-serialize.ts b/packages/core/src/kiwi/kiwi-serialize.ts index 5b183e2c8..4f0a91907 100644 --- a/packages/core/src/kiwi/kiwi-serialize.ts +++ b/packages/core/src/kiwi/kiwi-serialize.ts @@ -304,10 +304,10 @@ function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['t if (node.textAutoResize === 'NONE') return 'NONE' const parent = node.parentId ? graph.getNode(node.parentId) : undefined if ( - parent - && parent.layoutMode !== 'NONE' - && parent.layoutMode !== 'GRID' - && node.layoutPositioning !== 'ABSOLUTE' + parent && + parent.layoutMode !== 'NONE' && + parent.layoutMode !== 'GRID' && + node.layoutPositioning !== 'ABSOLUTE' ) { return 'NONE' } @@ -418,10 +418,7 @@ function serializeVariableBindings( if (entries.length > 0) nc.variableConsumptionMap = { entries } } -function computeExportTransform( - node: SceneNode, - graph: SceneGraph -): Matrix { +function computeExportTransform(node: SceneNode, graph: SceneGraph): Matrix { const sx = node.flipX ? -1 : 1 const cos = Math.cos((node.rotation * Math.PI) / 180) const sin = Math.sin((node.rotation * Math.PI) / 180) @@ -429,15 +426,18 @@ function computeExportTransform( // Auto-layout children should have (0,0) transform — Figma computes // their positions from the layout engine at render time. const parent = node.parentId ? graph.getNode(node.parentId) : undefined - const isAutoLayoutChild = parent - && parent.layoutMode !== 'NONE' - && parent.layoutMode !== 'GRID' - && node.layoutPositioning !== 'ABSOLUTE' + const isAutoLayoutChild = + parent && + parent.layoutMode !== 'NONE' && + parent.layoutMode !== 'GRID' && + node.layoutPositioning !== 'ABSOLUTE' return { - m00: cos * sx, m01: -sin, + m00: cos * sx, + m01: -sin, m02: isAutoLayoutChild ? 0 : node.x, - m10: sin * sx, m11: cos, + m10: sin * sx, + m11: cos, m12: isAutoLayoutChild ? 0 : node.y } } @@ -477,7 +477,7 @@ export function sceneNodeToKiwi( size: { x: node.width, y: node.height }, transform: computeExportTransform(node, graph), strokeWeight: node.strokes.length > 0 ? node.strokes[0].weight : 1, - strokeAlign: node.strokes.length > 0 ? node.strokes[0].align : 'INSIDE', + strokeAlign: node.strokes.length > 0 ? node.strokes[0].align : 'INSIDE' } if (node.independentStrokeWeights) { diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 5c57e3296..91c02a6ed 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -371,9 +371,10 @@ function createDefaultNode(type: NodeType, overrides: Partial = {}): width: 100, height: 100, rotation: 0, - fills: type === 'TEXT' - ? [{ type: 'SOLID' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] - : [], + fills: + type === 'TEXT' + ? [{ type: 'SOLID' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + : [], strokes: [], effects: [], opacity: 1, diff --git a/packages/vue/package.json b/packages/vue/package.json index c111279d5..88253b9b6 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -47,6 +47,9 @@ } }, "dependencies": { - "@vueuse/core": "^14.2.1" + "@nanostores/i18n": "^1.2.2", + "@nanostores/vue": "^1.1.0", + "@vueuse/core": "^14.2.1", + "nanostores": "^1.2.0" } } diff --git a/packages/vue/src/i18n/i18n.ts b/packages/vue/src/i18n/i18n.ts new file mode 100644 index 000000000..099401253 --- /dev/null +++ b/packages/vue/src/i18n/i18n.ts @@ -0,0 +1,14 @@ +import { createI18n } from '@nanostores/i18n' + +import { locale } from './locale' + +import type { Locale } from './locale' +import type { ComponentsJSON } from '@nanostores/i18n' + +export const i18n = createI18n(locale, { + baseLocale: 'en', + async get(code) { + const mod = (await import(`../locales/${code}.json`)) as { default: ComponentsJSON } + return mod.default + } +}) diff --git a/packages/vue/src/i18n/index.ts b/packages/vue/src/i18n/index.ts new file mode 100644 index 000000000..fb1ecf6cd --- /dev/null +++ b/packages/vue/src/i18n/index.ts @@ -0,0 +1,11 @@ +export { useI18n } from './useI18n' +export { locale, localeSetting, setLocale, AVAILABLE_LOCALES } from './locale' +export type { Locale } from './locale' +export { + menuMessages, + commandMessages, + toolMessages, + panelMessages, + pageMessages, + dialogMessages +} from './messages' diff --git a/packages/vue/src/i18n/locale.ts b/packages/vue/src/i18n/locale.ts new file mode 100644 index 000000000..67bb4f4e7 --- /dev/null +++ b/packages/vue/src/i18n/locale.ts @@ -0,0 +1,23 @@ +import { browser, localeFrom } from '@nanostores/i18n' +import { atom } from 'nanostores' + +export const AVAILABLE_LOCALES = ['en', 'de', 'es', 'fr', 'it', 'pl', 'ru'] as const +export type Locale = (typeof AVAILABLE_LOCALES)[number] + +export const localeSetting = atom(undefined) + +export const locale = localeFrom(localeSetting, browser({ available: AVAILABLE_LOCALES })) + +export function setLocale(code: Locale) { + localeSetting.set(code) + if (typeof localStorage !== 'undefined') { + localStorage.setItem('open-pencil-locale', code) + } +} + +if (typeof localStorage !== 'undefined') { + const saved = localStorage.getItem('open-pencil-locale') as Locale | null + if (saved && AVAILABLE_LOCALES.includes(saved)) { + localeSetting.set(saved) + } +} diff --git a/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts new file mode 100644 index 000000000..b34959d8d --- /dev/null +++ b/packages/vue/src/i18n/messages.ts @@ -0,0 +1,130 @@ +import { params } from '@nanostores/i18n' + +import { i18n } from './i18n' + +export const menuMessages = i18n('menu', { + file: 'File', + edit: 'Edit', + view: 'View', + object: 'Object', + arrange: 'Arrange', + text: 'Text', + + new: 'New', + open: 'Open…', + save: 'Save', + saveAs: 'Save as…', + exportSelection: 'Export selection…', + autosave: 'Auto-save to local file', + + copy: 'Copy', + paste: 'Paste', + + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + profiler: 'Performance profiler', + + moveToPage: 'Move to page', + createInstance: 'Create instance', + + bold: 'Bold', + italic: 'Italic', + underline: 'Underline' +}) + +export const commandMessages = i18n('commands', { + undo: 'Undo', + redo: 'Redo', + selectAll: 'Select all', + duplicate: 'Duplicate', + delete: 'Delete', + group: 'Group', + ungroup: 'Ungroup', + createComponent: 'Create component', + createComponentSet: 'Create component set', + createInstance: 'Create instance', + detachInstance: 'Detach instance', + goToMainComponent: 'Go to main component', + addAutoLayout: 'Add auto layout', + bringToFront: 'Bring to front', + sendToBack: 'Send to back', + toggleVisibility: 'Toggle visibility', + toggleLock: 'Toggle lock', + moveToPage: 'Move to page', + zoomTo100: 'Zoom to 100%', + zoomToFit: 'Zoom to fit', + zoomToSelection: 'Zoom to selection' +}) + +export const toolMessages = i18n('tools', { + move: 'Move', + frame: 'Frame', + section: 'Section', + rectangle: 'Rectangle', + ellipse: 'Ellipse', + line: 'Line', + polygon: 'Polygon', + star: 'Star', + pen: 'Pen', + text: 'Text', + hand: 'Hand' +}) + +export const panelMessages = i18n('panels', { + layers: 'Layers', + design: 'Design', + code: 'Code', + assets: 'Assets', + + position: 'Position', + layout: 'Layout', + appearance: 'Appearance', + fill: 'Fill', + stroke: 'Stroke', + effects: 'Effects', + export: 'Export', + typography: 'Typography', + variables: 'Variables', + constraints: 'Constraints', + + addFill: 'Add fill', + addStroke: 'Add stroke', + addEffect: 'Add effect', + addExport: 'Add export', + + dropShadow: 'Drop shadow', + innerShadow: 'Inner shadow', + layerBlur: 'Layer blur', + backgroundBlur: 'Background blur', + + noSelection: 'No selection', + mixed: 'Mixed', + + solid: 'Solid', + linearGradient: 'Linear', + radialGradient: 'Radial', + image: 'Image', + + alignLeft: 'Align left', + alignCenter: 'Align center', + alignRight: 'Align right', + alignTop: 'Align top', + alignMiddle: 'Align middle', + alignBottom: 'Align bottom' +}) + +export const pageMessages = i18n('pages', { + newPage: 'New page', + rename: 'Rename', + delete: 'Delete', + pageName: params('Page {number}') +}) + +export const dialogMessages = i18n('dialogs', { + cancel: 'Cancel', + apply: 'Apply', + close: 'Close', + ok: 'OK', + search: 'Search…', + noResults: 'No results' +}) diff --git a/packages/vue/src/i18n/useI18n.ts b/packages/vue/src/i18n/useI18n.ts new file mode 100644 index 000000000..66061592a --- /dev/null +++ b/packages/vue/src/i18n/useI18n.ts @@ -0,0 +1,46 @@ +import { useStore } from '@nanostores/vue' + +import { locale, setLocale, AVAILABLE_LOCALES } from './locale' +import { + menuMessages, + commandMessages, + toolMessages, + panelMessages, + pageMessages, + dialogMessages +} from './messages' + +import type { Locale } from './locale' +import type { Ref } from 'vue' + +/** + * Reactive i18n composable for OpenPencil Vue components. + * + * Returns reactive translation objects grouped by domain, plus locale + * controls. All values update automatically when the locale changes. + * + * @example + * ```vue + * + * + * + * ``` + */ +export function useI18n() { + return { + menu: useStore(menuMessages), + commands: useStore(commandMessages), + tools: useStore(toolMessages), + panels: useStore(panelMessages), + pages: useStore(pageMessages), + dialogs: useStore(dialogMessages), + locale: useStore(locale) as Ref, + availableLocales: AVAILABLE_LOCALES, + setLocale + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 9f8b29863..f04e96015 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -86,3 +86,16 @@ export type { ScrubInputContext } from './ScrubInput' export { TypographyControlsRoot } from './TypographyControls' export { ToolbarRoot, ToolbarItem, useToolbar } from './Toolbar' export type { ToolbarContext } from './Toolbar' + +/** Internationalization. */ +export { useI18n } from './i18n' +export { locale, localeSetting, setLocale, AVAILABLE_LOCALES } from './i18n' +export type { Locale } from './i18n' +export { + menuMessages, + commandMessages, + toolMessages, + panelMessages, + pageMessages, + dialogMessages +} from './i18n' diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json new file mode 100644 index 000000000..f04d26f04 --- /dev/null +++ b/packages/vue/src/locales/ru.json @@ -0,0 +1,112 @@ +{ + "menu": { + "file": "Файл", + "edit": "Правка", + "view": "Вид", + "object": "Объект", + "arrange": "Порядок", + "text": "Текст", + "new": "Создать", + "open": "Открыть…", + "save": "Сохранить", + "saveAs": "Сохранить как…", + "exportSelection": "Экспорт выделения…", + "autosave": "Автосохранение в файл", + "copy": "Копировать", + "paste": "Вставить", + "zoomIn": "Увеличить", + "zoomOut": "Уменьшить", + "profiler": "Профилирование", + "moveToPage": "Переместить на страницу", + "createInstance": "Создать экземпляр", + "bold": "Жирный", + "italic": "Курсив", + "underline": "Подчёркнутый" + }, + "commands": { + "undo": "Отменить", + "redo": "Повторить", + "selectAll": "Выделить всё", + "duplicate": "Дублировать", + "delete": "Удалить", + "group": "Сгруппировать", + "ungroup": "Разгруппировать", + "createComponent": "Создать компонент", + "createComponentSet": "Создать набор компонентов", + "createInstance": "Создать экземпляр", + "detachInstance": "Отвязать экземпляр", + "goToMainComponent": "Перейти к компоненту", + "addAutoLayout": "Добавить авто-раскладку", + "bringToFront": "На передний план", + "sendToBack": "На задний план", + "toggleVisibility": "Видимость", + "toggleLock": "Блокировка", + "moveToPage": "Переместить на страницу", + "zoomTo100": "Масштаб 100%", + "zoomToFit": "Вписать в экран", + "zoomToSelection": "Масштаб по выделению" + }, + "tools": { + "move": "Перемещение", + "frame": "Фрейм", + "section": "Секция", + "rectangle": "Прямоугольник", + "ellipse": "Эллипс", + "line": "Линия", + "polygon": "Многоугольник", + "star": "Звезда", + "pen": "Перо", + "text": "Текст", + "hand": "Рука" + }, + "panels": { + "layers": "Слои", + "design": "Дизайн", + "code": "Код", + "assets": "Ассеты", + "position": "Позиция", + "layout": "Раскладка", + "appearance": "Внешний вид", + "fill": "Заливка", + "stroke": "Обводка", + "effects": "Эффекты", + "export": "Экспорт", + "typography": "Типографика", + "variables": "Переменные", + "constraints": "Ограничения", + "addFill": "Добавить заливку", + "addStroke": "Добавить обводку", + "addEffect": "Добавить эффект", + "addExport": "Добавить экспорт", + "dropShadow": "Тень", + "innerShadow": "Внутренняя тень", + "layerBlur": "Размытие слоя", + "backgroundBlur": "Размытие фона", + "noSelection": "Ничего не выделено", + "mixed": "Смешанное", + "solid": "Сплошной", + "linearGradient": "Линейный", + "radialGradient": "Радиальный", + "image": "Изображение", + "alignLeft": "По левому краю", + "alignCenter": "По центру", + "alignRight": "По правому краю", + "alignTop": "По верхнему краю", + "alignMiddle": "По середине", + "alignBottom": "По нижнему краю" + }, + "pages": { + "newPage": "Новая страница", + "rename": "Переименовать", + "delete": "Удалить", + "pageName": "Страница {number}" + }, + "dialogs": { + "cancel": "Отмена", + "apply": "Применить", + "close": "Закрыть", + "ok": "ОК", + "search": "Поиск…", + "noResults": "Ничего не найдено" + } +} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 7e8f5cfe4..6a33bd6b6 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -10,7 +10,8 @@ "outDir": "./dist", "rootDir": "./src", "skipLibCheck": true, - "esModuleInterop": true + "esModuleInterop": true, + "resolveJsonModule": true }, "include": ["src/**/*.ts", "src/**/*.vue"], "exclude": ["node_modules", "dist", "example"] diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts index 247018abd..68f939baf 100644 --- a/src/composables/use-keyboard.ts +++ b/src/composables/use-keyboard.ts @@ -95,7 +95,7 @@ export function useKeyboard() { }) // Spacebar hold → temporary Hand tool (Figma-style canvas pan) - let toolBeforeSpace: (typeof store.state.activeTool) | null = null + let toolBeforeSpace: typeof store.state.activeTool | null = null useEventListener(window, 'keydown', (e: KeyboardEvent) => { if (isEditing(e)) return From eb2891233ac2b1587a3a92e54b7411b13a42e7ce Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 25 Mar 2026 17:05:11 +0300 Subject: [PATCH 02/14] Wire i18n into commands, menus, and add locale files Commands and menu models now read labels from nanostores i18n messages instead of hardcoded English strings. App menu rebuilt to use i18n directly instead of matching on localized labels. Added locale files: de, es, fr, it, pl (ru was added earlier). All 6 translations plus English base cover menus, commands, tools, panels, pages, and dialogs. --- .../vue/src/commands/useEditorCommands.ts | 88 +++++-- packages/vue/src/commands/useMenuModel.ts | 18 +- packages/vue/src/locales/de.json | 112 ++++++++ packages/vue/src/locales/es.json | 112 ++++++++ packages/vue/src/locales/fr.json | 112 ++++++++ packages/vue/src/locales/it.json | 112 ++++++++ packages/vue/src/locales/pl.json | 112 ++++++++ src/composables/use-app-menu.ts | 244 +++++++++--------- 8 files changed, 760 insertions(+), 150 deletions(-) create mode 100644 packages/vue/src/locales/de.json create mode 100644 packages/vue/src/locales/es.json create mode 100644 packages/vue/src/locales/fr.json create mode 100644 packages/vue/src/locales/it.json create mode 100644 packages/vue/src/locales/pl.json diff --git a/packages/vue/src/commands/useEditorCommands.ts b/packages/vue/src/commands/useEditorCommands.ts index 06f886507..0c64597c1 100644 --- a/packages/vue/src/commands/useEditorCommands.ts +++ b/packages/vue/src/commands/useEditorCommands.ts @@ -1,6 +1,8 @@ +import { useStore } from '@nanostores/vue' import { computed } from 'vue' import { useEditor } from '@open-pencil/vue/context/editorContext' +import { commandMessages } from '@open-pencil/vue/i18n' import { usePageList } from '@open-pencil/vue/PageList/usePageList' import { useSelectionCapabilities } from '@open-pencil/vue/selection/useSelectionCapabilities' import { useSelectionState } from '@open-pencil/vue/selection/useSelectionState' @@ -76,64 +78,86 @@ export function useEditorCommands() { const capabilities = useSelectionCapabilities() const { pages } = usePageList() + const t = useStore(commandMessages) + const commands: Record = { 'edit.undo': { id: 'edit.undo', - label: 'Undo', + get label() { + return t.value.undo + }, enabled: capabilities.canUndo, run: () => editor.undoAction() }, 'edit.redo': { id: 'edit.redo', - label: 'Redo', + get label() { + return t.value.redo + }, enabled: capabilities.canRedo, run: () => editor.redoAction() }, 'selection.selectAll': { id: 'selection.selectAll', - label: 'Select all', + get label() { + return t.value.selectAll + }, enabled: capabilities.canSelectAll, run: () => editor.selectAll() }, 'selection.duplicate': { id: 'selection.duplicate', - label: 'Duplicate', + get label() { + return t.value.duplicate + }, enabled: capabilities.canDuplicate, run: () => editor.duplicateSelected() }, 'selection.delete': { id: 'selection.delete', - label: 'Delete', + get label() { + return t.value.delete + }, enabled: capabilities.canDelete, run: () => editor.deleteSelected() }, 'selection.group': { id: 'selection.group', - label: 'Group', + get label() { + return t.value.group + }, enabled: capabilities.canGroup, run: () => editor.groupSelected() }, 'selection.ungroup': { id: 'selection.ungroup', - label: 'Ungroup', + get label() { + return t.value.ungroup + }, enabled: capabilities.canUngroup, run: () => editor.ungroupSelected() }, 'selection.createComponent': { id: 'selection.createComponent', - label: 'Create component', + get label() { + return t.value.createComponent + }, enabled: capabilities.canCreateComponent, run: () => editor.createComponentFromSelection() }, 'selection.createComponentSet': { id: 'selection.createComponentSet', - label: 'Create component set', + get label() { + return t.value.createComponentSet + }, enabled: capabilities.canCreateComponentSet, run: () => editor.createComponentSetFromComponents() }, 'selection.createInstance': { id: 'selection.createInstance', - label: 'Create instance', + get label() { + return t.value.createInstance + }, enabled: capabilities.canCreateInstance, run: () => { const node = selection.selectedNode.value @@ -142,49 +166,65 @@ export function useEditorCommands() { }, 'selection.detachInstance': { id: 'selection.detachInstance', - label: 'Detach instance', + get label() { + return t.value.detachInstance + }, enabled: capabilities.canDetachInstance, run: () => editor.detachInstance() }, 'selection.goToMainComponent': { id: 'selection.goToMainComponent', - label: 'Go to main component', + get label() { + return t.value.goToMainComponent + }, enabled: capabilities.canGoToMainComponent, run: () => editor.goToMainComponent() }, 'selection.wrapInAutoLayout': { id: 'selection.wrapInAutoLayout', - label: 'Add auto layout', + get label() { + return t.value.addAutoLayout + }, enabled: capabilities.canWrapInAutoLayout, run: () => editor.wrapInAutoLayout() }, 'selection.bringToFront': { id: 'selection.bringToFront', - label: 'Bring to front', + get label() { + return t.value.bringToFront + }, enabled: capabilities.canBringToFront, run: () => editor.bringToFront() }, 'selection.sendToBack': { id: 'selection.sendToBack', - label: 'Send to back', + get label() { + return t.value.sendToBack + }, enabled: capabilities.canSendToBack, run: () => editor.sendToBack() }, 'selection.toggleVisibility': { id: 'selection.toggleVisibility', - label: 'Toggle visibility', + get label() { + return t.value.toggleVisibility + }, enabled: capabilities.canToggleVisibility, run: () => editor.toggleVisibility() }, 'selection.toggleLock': { id: 'selection.toggleLock', - label: 'Toggle lock', + get label() { + return t.value.toggleLock + }, enabled: capabilities.canToggleLock, run: () => editor.toggleLock() }, 'selection.moveToPage': { id: 'selection.moveToPage', - label: 'Move to page', + get label() { + return t.value.moveToPage + }, enabled: capabilities.canMoveToPage, run: () => { const targetPage = otherPages.value[0] @@ -193,19 +233,25 @@ export function useEditorCommands() { }, 'view.zoom100': { id: 'view.zoom100', - label: 'Zoom to 100%', + get label() { + return t.value.zoomTo100 + }, enabled: computed(() => true), run: () => editor.zoomTo100() }, 'view.zoomFit': { id: 'view.zoomFit', - label: 'Zoom to fit', + get label() { + return t.value.zoomToFit + }, enabled: computed(() => true), run: () => editor.zoomToFit() }, 'view.zoomSelection': { id: 'view.zoomSelection', - label: 'Zoom to selection', + get label() { + return t.value.zoomToSelection + }, enabled: capabilities.canZoomToSelection, run: () => editor.zoomToSelection() } diff --git a/packages/vue/src/commands/useMenuModel.ts b/packages/vue/src/commands/useMenuModel.ts index 9735065b9..b32fd52b8 100644 --- a/packages/vue/src/commands/useMenuModel.ts +++ b/packages/vue/src/commands/useMenuModel.ts @@ -1,7 +1,9 @@ +import { useStore } from '@nanostores/vue' import { computed } from 'vue' import { useEditorCommands } from '@open-pencil/vue/commands/useEditorCommands' import { useEditor } from '@open-pencil/vue/context/editorContext' +import { menuMessages } from '@open-pencil/vue/i18n' import { useSelectionState } from '@open-pencil/vue/selection/useSelectionState' /** @@ -37,6 +39,8 @@ export function useMenuModel() { const { hasSelection, isGroup, isInstance, isComponent, canCreateComponentSet, selectedNode } = useSelectionState() + const t = useStore(menuMessages) + const editMenu = computed(() => [ commandMenuItem('edit.undo'), commandMenuItem('edit.redo'), @@ -68,10 +72,10 @@ export function useMenuModel() { const arrangeMenu = computed(() => [commandMenuItem('selection.wrapInAutoLayout')]) const appMenu = computed(() => [ - { label: 'Edit', items: editMenu.value }, - { label: 'View', items: viewMenu.value }, - { label: 'Object', items: objectMenu.value }, - { label: 'Arrange', items: arrangeMenu.value } + { label: t.value.edit, items: editMenu.value }, + { label: t.value.view, items: viewMenu.value }, + { label: t.value.object, items: objectMenu.value }, + { label: t.value.arrange, items: arrangeMenu.value } ]) const canvasMenu = computed(() => { @@ -81,11 +85,11 @@ export function useMenuModel() { })) return [ - { ...commandMenuItem('selection.duplicate', '⌘D'), label: 'Duplicate' }, + commandMenuItem('selection.duplicate', '⌘D'), commandMenuItem('selection.delete', '⌫'), { separator: true }, ...(moveToPageSubmenu.length > 0 && hasSelection.value - ? [{ label: 'Move to page', sub: moveToPageSubmenu } satisfies MenuActionNode] + ? [{ label: t.value.moveToPage, sub: moveToPageSubmenu } satisfies MenuActionNode] : []), commandMenuItem('selection.bringToFront', ']'), commandMenuItem('selection.sendToBack', '['), @@ -101,7 +105,7 @@ export function useMenuModel() { ...(isComponent.value && selectedNode.value ? [ { - label: 'Create instance', + label: t.value.createInstance, action: () => commandMenuItem('selection.createInstance').action?.(), disabled: !commandMenuItem('selection.createInstance').disabled } satisfies MenuActionNode diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json new file mode 100644 index 000000000..5eb60cb0d --- /dev/null +++ b/packages/vue/src/locales/de.json @@ -0,0 +1,112 @@ +{ + "menu": { + "file": "Datei", + "edit": "Bearbeiten", + "view": "Ansicht", + "object": "Objekt", + "arrange": "Anordnen", + "text": "Text", + "new": "Neu", + "open": "Öffnen…", + "save": "Speichern", + "saveAs": "Speichern unter…", + "exportSelection": "Auswahl exportieren…", + "autosave": "Automatisch in Datei speichern", + "copy": "Kopieren", + "paste": "Einfügen", + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern", + "profiler": "Leistungsprofil", + "moveToPage": "Auf Seite verschieben", + "createInstance": "Instanz erstellen", + "bold": "Fett", + "italic": "Kursiv", + "underline": "Unterstrichen" + }, + "commands": { + "undo": "Rückgängig", + "redo": "Wiederholen", + "selectAll": "Alles auswählen", + "duplicate": "Duplizieren", + "delete": "Löschen", + "group": "Gruppieren", + "ungroup": "Gruppierung aufheben", + "createComponent": "Komponente erstellen", + "createComponentSet": "Komponentensatz erstellen", + "createInstance": "Instanz erstellen", + "detachInstance": "Instanz lösen", + "goToMainComponent": "Zur Hauptkomponente", + "addAutoLayout": "Auto-Layout hinzufügen", + "bringToFront": "In den Vordergrund", + "sendToBack": "In den Hintergrund", + "toggleVisibility": "Sichtbarkeit", + "toggleLock": "Sperren", + "moveToPage": "Auf Seite verschieben", + "zoomTo100": "Zoom 100 %", + "zoomToFit": "Einpassen", + "zoomToSelection": "Auswahl einpassen" + }, + "tools": { + "move": "Verschieben", + "frame": "Rahmen", + "section": "Abschnitt", + "rectangle": "Rechteck", + "ellipse": "Ellipse", + "line": "Linie", + "polygon": "Polygon", + "star": "Stern", + "pen": "Stift", + "text": "Text", + "hand": "Hand" + }, + "panels": { + "layers": "Ebenen", + "design": "Design", + "code": "Code", + "assets": "Elemente", + "position": "Position", + "layout": "Layout", + "appearance": "Darstellung", + "fill": "Füllung", + "stroke": "Kontur", + "effects": "Effekte", + "export": "Export", + "typography": "Typografie", + "variables": "Variablen", + "constraints": "Einschränkungen", + "addFill": "Füllung hinzufügen", + "addStroke": "Kontur hinzufügen", + "addEffect": "Effekt hinzufügen", + "addExport": "Export hinzufügen", + "dropShadow": "Schlagschatten", + "innerShadow": "Innerer Schatten", + "layerBlur": "Ebenenunschärfe", + "backgroundBlur": "Hintergrundunschärfe", + "noSelection": "Keine Auswahl", + "mixed": "Gemischt", + "solid": "Vollton", + "linearGradient": "Linear", + "radialGradient": "Radial", + "image": "Bild", + "alignLeft": "Links ausrichten", + "alignCenter": "Zentrieren", + "alignRight": "Rechts ausrichten", + "alignTop": "Oben ausrichten", + "alignMiddle": "Mittig ausrichten", + "alignBottom": "Unten ausrichten" + }, + "pages": { + "newPage": "Neue Seite", + "rename": "Umbenennen", + "delete": "Löschen", + "pageName": "Seite {number}" + }, + "dialogs": { + "cancel": "Abbrechen", + "apply": "Anwenden", + "close": "Schließen", + "ok": "OK", + "search": "Suchen…", + "noResults": "Keine Ergebnisse" + } +} diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json new file mode 100644 index 000000000..a630fc279 --- /dev/null +++ b/packages/vue/src/locales/es.json @@ -0,0 +1,112 @@ +{ + "menu": { + "file": "Archivo", + "edit": "Editar", + "view": "Ver", + "object": "Objeto", + "arrange": "Organizar", + "text": "Texto", + "new": "Nuevo", + "open": "Abrir…", + "save": "Guardar", + "saveAs": "Guardar como…", + "exportSelection": "Exportar selección…", + "autosave": "Guardado automático en archivo", + "copy": "Copiar", + "paste": "Pegar", + "zoomIn": "Ampliar", + "zoomOut": "Reducir", + "profiler": "Perfil de rendimiento", + "moveToPage": "Mover a página", + "createInstance": "Crear instancia", + "bold": "Negrita", + "italic": "Cursiva", + "underline": "Subrayado" + }, + "commands": { + "undo": "Deshacer", + "redo": "Rehacer", + "selectAll": "Seleccionar todo", + "duplicate": "Duplicar", + "delete": "Eliminar", + "group": "Agrupar", + "ungroup": "Desagrupar", + "createComponent": "Crear componente", + "createComponentSet": "Crear conjunto de componentes", + "createInstance": "Crear instancia", + "detachInstance": "Separar instancia", + "goToMainComponent": "Ir al componente principal", + "addAutoLayout": "Añadir auto-layout", + "bringToFront": "Traer al frente", + "sendToBack": "Enviar al fondo", + "toggleVisibility": "Visibilidad", + "toggleLock": "Bloqueo", + "moveToPage": "Mover a página", + "zoomTo100": "Zoom 100 %", + "zoomToFit": "Ajustar a pantalla", + "zoomToSelection": "Zoom a selección" + }, + "tools": { + "move": "Mover", + "frame": "Marco", + "section": "Sección", + "rectangle": "Rectángulo", + "ellipse": "Elipse", + "line": "Línea", + "polygon": "Polígono", + "star": "Estrella", + "pen": "Pluma", + "text": "Texto", + "hand": "Mano" + }, + "panels": { + "layers": "Capas", + "design": "Diseño", + "code": "Código", + "assets": "Recursos", + "position": "Posición", + "layout": "Diseño", + "appearance": "Apariencia", + "fill": "Relleno", + "stroke": "Trazo", + "effects": "Efectos", + "export": "Exportar", + "typography": "Tipografía", + "variables": "Variables", + "constraints": "Restricciones", + "addFill": "Añadir relleno", + "addStroke": "Añadir trazo", + "addEffect": "Añadir efecto", + "addExport": "Añadir exportación", + "dropShadow": "Sombra", + "innerShadow": "Sombra interior", + "layerBlur": "Desenfoque de capa", + "backgroundBlur": "Desenfoque de fondo", + "noSelection": "Sin selección", + "mixed": "Mixto", + "solid": "Sólido", + "linearGradient": "Lineal", + "radialGradient": "Radial", + "image": "Imagen", + "alignLeft": "Alinear a la izquierda", + "alignCenter": "Centrar", + "alignRight": "Alinear a la derecha", + "alignTop": "Alinear arriba", + "alignMiddle": "Centrar verticalmente", + "alignBottom": "Alinear abajo" + }, + "pages": { + "newPage": "Nueva página", + "rename": "Renombrar", + "delete": "Eliminar", + "pageName": "Página {number}" + }, + "dialogs": { + "cancel": "Cancelar", + "apply": "Aplicar", + "close": "Cerrar", + "ok": "Aceptar", + "search": "Buscar…", + "noResults": "Sin resultados" + } +} diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json new file mode 100644 index 000000000..fd6637b7d --- /dev/null +++ b/packages/vue/src/locales/fr.json @@ -0,0 +1,112 @@ +{ + "menu": { + "file": "Fichier", + "edit": "Édition", + "view": "Affichage", + "object": "Objet", + "arrange": "Disposition", + "text": "Texte", + "new": "Nouveau", + "open": "Ouvrir…", + "save": "Enregistrer", + "saveAs": "Enregistrer sous…", + "exportSelection": "Exporter la sélection…", + "autosave": "Enregistrement automatique", + "copy": "Copier", + "paste": "Coller", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "profiler": "Profileur de performance", + "moveToPage": "Déplacer vers la page", + "createInstance": "Créer une instance", + "bold": "Gras", + "italic": "Italique", + "underline": "Souligné" + }, + "commands": { + "undo": "Annuler", + "redo": "Rétablir", + "selectAll": "Tout sélectionner", + "duplicate": "Dupliquer", + "delete": "Supprimer", + "group": "Grouper", + "ungroup": "Dégrouper", + "createComponent": "Créer un composant", + "createComponentSet": "Créer un jeu de composants", + "createInstance": "Créer une instance", + "detachInstance": "Détacher l'instance", + "goToMainComponent": "Aller au composant principal", + "addAutoLayout": "Ajouter un auto-layout", + "bringToFront": "Mettre au premier plan", + "sendToBack": "Mettre en arrière-plan", + "toggleVisibility": "Visibilité", + "toggleLock": "Verrouillage", + "moveToPage": "Déplacer vers la page", + "zoomTo100": "Zoom 100 %", + "zoomToFit": "Ajuster à l'écran", + "zoomToSelection": "Zoom sur la sélection" + }, + "tools": { + "move": "Déplacer", + "frame": "Cadre", + "section": "Section", + "rectangle": "Rectangle", + "ellipse": "Ellipse", + "line": "Ligne", + "polygon": "Polygone", + "star": "Étoile", + "pen": "Plume", + "text": "Texte", + "hand": "Main" + }, + "panels": { + "layers": "Calques", + "design": "Design", + "code": "Code", + "assets": "Ressources", + "position": "Position", + "layout": "Disposition", + "appearance": "Apparence", + "fill": "Remplissage", + "stroke": "Contour", + "effects": "Effets", + "export": "Export", + "typography": "Typographie", + "variables": "Variables", + "constraints": "Contraintes", + "addFill": "Ajouter un remplissage", + "addStroke": "Ajouter un contour", + "addEffect": "Ajouter un effet", + "addExport": "Ajouter un export", + "dropShadow": "Ombre portée", + "innerShadow": "Ombre interne", + "layerBlur": "Flou de calque", + "backgroundBlur": "Flou d'arrière-plan", + "noSelection": "Aucune sélection", + "mixed": "Mixte", + "solid": "Uni", + "linearGradient": "Linéaire", + "radialGradient": "Radial", + "image": "Image", + "alignLeft": "Aligner à gauche", + "alignCenter": "Centrer", + "alignRight": "Aligner à droite", + "alignTop": "Aligner en haut", + "alignMiddle": "Centrer verticalement", + "alignBottom": "Aligner en bas" + }, + "pages": { + "newPage": "Nouvelle page", + "rename": "Renommer", + "delete": "Supprimer", + "pageName": "Page {number}" + }, + "dialogs": { + "cancel": "Annuler", + "apply": "Appliquer", + "close": "Fermer", + "ok": "OK", + "search": "Rechercher…", + "noResults": "Aucun résultat" + } +} diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json new file mode 100644 index 000000000..c33846614 --- /dev/null +++ b/packages/vue/src/locales/it.json @@ -0,0 +1,112 @@ +{ + "menu": { + "file": "File", + "edit": "Modifica", + "view": "Visualizza", + "object": "Oggetto", + "arrange": "Disponi", + "text": "Testo", + "new": "Nuovo", + "open": "Apri…", + "save": "Salva", + "saveAs": "Salva con nome…", + "exportSelection": "Esporta selezione…", + "autosave": "Salvataggio automatico su file", + "copy": "Copia", + "paste": "Incolla", + "zoomIn": "Ingrandisci", + "zoomOut": "Riduci", + "profiler": "Profilo prestazioni", + "moveToPage": "Sposta nella pagina", + "createInstance": "Crea istanza", + "bold": "Grassetto", + "italic": "Corsivo", + "underline": "Sottolineato" + }, + "commands": { + "undo": "Annulla", + "redo": "Ripeti", + "selectAll": "Seleziona tutto", + "duplicate": "Duplica", + "delete": "Elimina", + "group": "Raggruppa", + "ungroup": "Separa", + "createComponent": "Crea componente", + "createComponentSet": "Crea set di componenti", + "createInstance": "Crea istanza", + "detachInstance": "Scollega istanza", + "goToMainComponent": "Vai al componente principale", + "addAutoLayout": "Aggiungi auto-layout", + "bringToFront": "Porta in primo piano", + "sendToBack": "Porta in secondo piano", + "toggleVisibility": "Visibilità", + "toggleLock": "Blocco", + "moveToPage": "Sposta nella pagina", + "zoomTo100": "Zoom 100%", + "zoomToFit": "Adatta allo schermo", + "zoomToSelection": "Zoom sulla selezione" + }, + "tools": { + "move": "Sposta", + "frame": "Cornice", + "section": "Sezione", + "rectangle": "Rettangolo", + "ellipse": "Ellisse", + "line": "Linea", + "polygon": "Poligono", + "star": "Stella", + "pen": "Penna", + "text": "Testo", + "hand": "Mano" + }, + "panels": { + "layers": "Livelli", + "design": "Design", + "code": "Codice", + "assets": "Risorse", + "position": "Posizione", + "layout": "Layout", + "appearance": "Aspetto", + "fill": "Riempimento", + "stroke": "Contorno", + "effects": "Effetti", + "export": "Esporta", + "typography": "Tipografia", + "variables": "Variabili", + "constraints": "Vincoli", + "addFill": "Aggiungi riempimento", + "addStroke": "Aggiungi contorno", + "addEffect": "Aggiungi effetto", + "addExport": "Aggiungi esportazione", + "dropShadow": "Ombra esterna", + "innerShadow": "Ombra interna", + "layerBlur": "Sfocatura livello", + "backgroundBlur": "Sfocatura sfondo", + "noSelection": "Nessuna selezione", + "mixed": "Misto", + "solid": "Pieno", + "linearGradient": "Lineare", + "radialGradient": "Radiale", + "image": "Immagine", + "alignLeft": "Allinea a sinistra", + "alignCenter": "Centra", + "alignRight": "Allinea a destra", + "alignTop": "Allinea in alto", + "alignMiddle": "Centra verticalmente", + "alignBottom": "Allinea in basso" + }, + "pages": { + "newPage": "Nuova pagina", + "rename": "Rinomina", + "delete": "Elimina", + "pageName": "Pagina {number}" + }, + "dialogs": { + "cancel": "Annulla", + "apply": "Applica", + "close": "Chiudi", + "ok": "OK", + "search": "Cerca…", + "noResults": "Nessun risultato" + } +} diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json new file mode 100644 index 000000000..18b9f10d4 --- /dev/null +++ b/packages/vue/src/locales/pl.json @@ -0,0 +1,112 @@ +{ + "menu": { + "file": "Plik", + "edit": "Edycja", + "view": "Widok", + "object": "Obiekt", + "arrange": "Rozmieszczenie", + "text": "Tekst", + "new": "Nowy", + "open": "Otwórz…", + "save": "Zapisz", + "saveAs": "Zapisz jako…", + "exportSelection": "Eksportuj zaznaczenie…", + "autosave": "Automatyczny zapis do pliku", + "copy": "Kopiuj", + "paste": "Wklej", + "zoomIn": "Powiększ", + "zoomOut": "Pomniejsz", + "profiler": "Profiler wydajności", + "moveToPage": "Przenieś na stronę", + "createInstance": "Utwórz instancję", + "bold": "Pogrubienie", + "italic": "Kursywa", + "underline": "Podkreślenie" + }, + "commands": { + "undo": "Cofnij", + "redo": "Ponów", + "selectAll": "Zaznacz wszystko", + "duplicate": "Duplikuj", + "delete": "Usuń", + "group": "Grupuj", + "ungroup": "Rozgrupuj", + "createComponent": "Utwórz komponent", + "createComponentSet": "Utwórz zestaw komponentów", + "createInstance": "Utwórz instancję", + "detachInstance": "Odłącz instancję", + "goToMainComponent": "Przejdź do komponentu", + "addAutoLayout": "Dodaj auto-layout", + "bringToFront": "Przenieś na wierzch", + "sendToBack": "Przenieś na spód", + "toggleVisibility": "Widoczność", + "toggleLock": "Blokada", + "moveToPage": "Przenieś na stronę", + "zoomTo100": "Zoom 100%", + "zoomToFit": "Dopasuj do ekranu", + "zoomToSelection": "Zoom do zaznaczenia" + }, + "tools": { + "move": "Przesuń", + "frame": "Ramka", + "section": "Sekcja", + "rectangle": "Prostokąt", + "ellipse": "Elipsa", + "line": "Linia", + "polygon": "Wielokąt", + "star": "Gwiazda", + "pen": "Pióro", + "text": "Tekst", + "hand": "Dłoń" + }, + "panels": { + "layers": "Warstwy", + "design": "Design", + "code": "Kod", + "assets": "Zasoby", + "position": "Pozycja", + "layout": "Układ", + "appearance": "Wygląd", + "fill": "Wypełnienie", + "stroke": "Obrys", + "effects": "Efekty", + "export": "Eksport", + "typography": "Typografia", + "variables": "Zmienne", + "constraints": "Ograniczenia", + "addFill": "Dodaj wypełnienie", + "addStroke": "Dodaj obrys", + "addEffect": "Dodaj efekt", + "addExport": "Dodaj eksport", + "dropShadow": "Cień", + "innerShadow": "Cień wewnętrzny", + "layerBlur": "Rozmycie warstwy", + "backgroundBlur": "Rozmycie tła", + "noSelection": "Brak zaznaczenia", + "mixed": "Mieszane", + "solid": "Jednolity", + "linearGradient": "Liniowy", + "radialGradient": "Promieniowy", + "image": "Obraz", + "alignLeft": "Wyrównaj do lewej", + "alignCenter": "Wyśrodkuj", + "alignRight": "Wyrównaj do prawej", + "alignTop": "Wyrównaj do góry", + "alignMiddle": "Wyśrodkuj w pionie", + "alignBottom": "Wyrównaj do dołu" + }, + "pages": { + "newPage": "Nowa strona", + "rename": "Zmień nazwę", + "delete": "Usuń", + "pageName": "Strona {number}" + }, + "dialogs": { + "cancel": "Anuluj", + "apply": "Zastosuj", + "close": "Zamknij", + "ok": "OK", + "search": "Szukaj…", + "noResults": "Brak wyników" + } +} diff --git a/src/composables/use-app-menu.ts b/src/composables/use-app-menu.ts index 3f75fbcf1..523053e10 100644 --- a/src/composables/use-app-menu.ts +++ b/src/composables/use-app-menu.ts @@ -2,139 +2,139 @@ import { computed } from 'vue' import { openFileDialog } from '@/composables/use-menu' import { useEditorStore } from '@/stores/editor' -import { useEditorCommands, useMenuModel } from '@open-pencil/vue' +import { useEditorCommands, useI18n } from '@open-pencil/vue' + +import type { MenuEntry } from '@open-pencil/vue' + +export interface AppMenuGroup { + label: string + items: MenuEntry[] +} export function useAppMenu(mod: string) { const store = useEditorStore() const { menuItem: commandMenuItem } = useEditorCommands() - const { appMenu } = useMenuModel() + const { menu: t, panels: p } = useI18n() - const textMenu = [ - { label: 'Bold', shortcut: `${mod}B` }, - { label: 'Italic', shortcut: `${mod}I` }, - { label: 'Underline', shortcut: `${mod}U` } - ] - - const fileMenu = [ + const topMenus = computed(() => [ { - label: 'New', - shortcut: `${mod}N`, - action: () => import('@/stores/tabs').then((m) => m.createTab()) + label: t.value.file, + items: [ + { + label: t.value.new, + shortcut: `${mod}N`, + action: () => { + void import('@/stores/tabs').then((m) => m.createTab()) + } + }, + { label: t.value.open, shortcut: `${mod}O`, action: () => void openFileDialog() }, + { separator: true as const }, + { label: t.value.save, shortcut: `${mod}S`, action: () => void store.saveFigFile() }, + { + label: t.value.saveAs, + shortcut: `${mod}⇧S`, + action: () => void store.saveFigFileAs() + }, + { separator: true as const }, + { + label: t.value.exportSelection, + shortcut: `${mod}⇧E`, + action: () => { + void store.exportSelection(1, 'PNG') + }, + disabled: store.state.selectedIds.size === 0 + }, + { separator: true as const }, + { + label: t.value.autosave, + get checked() { + return store.state.autosaveEnabled + }, + onCheckedChange: (value: boolean) => { + store.state.autosaveEnabled = value + } + } + ] }, - { label: 'Open…', shortcut: `${mod}O`, action: () => void openFileDialog() }, - { separator: true as const }, - { label: 'Save', shortcut: `${mod}S`, action: () => void store.saveFigFile() }, - { label: 'Save as…', shortcut: `${mod}⇧S`, action: () => void store.saveFigFileAs() }, - { separator: true as const }, { - label: 'Export selection…', - shortcut: `${mod}⇧E`, - action: () => void store.exportSelection(1, 'PNG'), - disabled: store.state.selectedIds.size === 0 + label: t.value.edit, + items: [ + commandMenuItem('edit.undo', `${mod}Z`), + commandMenuItem('edit.redo', `${mod}⇧Z`), + { separator: true as const }, + { label: t.value.copy, shortcut: `${mod}C` }, + { label: t.value.paste, shortcut: `${mod}V` }, + commandMenuItem('selection.duplicate', `${mod}D`), + commandMenuItem('selection.delete', '⌫'), + { separator: true as const }, + commandMenuItem('selection.selectAll', `${mod}A`) + ] }, - { separator: true as const }, { - label: 'Auto-save to local file', - get checked() { - return store.state.autosaveEnabled - }, - onCheckedChange: (value: boolean) => { - store.state.autosaveEnabled = value - } + label: t.value.view, + items: [ + commandMenuItem('view.zoom100', `${mod}0`), + commandMenuItem('view.zoomFit', `${mod}1`), + commandMenuItem('view.zoomSelection', `${mod}2`), + { + label: t.value.zoomIn, + shortcut: `${mod}=`, + action: () => store.applyZoom(-100, window.innerWidth / 2, window.innerHeight / 2) + }, + { + label: t.value.zoomOut, + shortcut: `${mod}-`, + action: () => store.applyZoom(100, window.innerWidth / 2, window.innerHeight / 2) + }, + { separator: true as const }, + { + label: t.value.profiler, + get checked() { + return store.renderer?.profiler.hudVisible ?? false + }, + onCheckedChange: () => { + store.toggleProfiler() + } + } + ] + }, + { + label: t.value.object, + items: [ + commandMenuItem('selection.group', `${mod}G`), + commandMenuItem('selection.ungroup', `${mod}⇧G`), + { separator: true as const }, + commandMenuItem('selection.createComponent', `${mod}⌥K`), + commandMenuItem('selection.createComponentSet'), + commandMenuItem('selection.detachInstance'), + { separator: true as const }, + commandMenuItem('selection.bringToFront', ']'), + commandMenuItem('selection.sendToBack', '[') + ] + }, + { + label: t.value.arrange, + items: [ + commandMenuItem('selection.wrapInAutoLayout', '⇧A'), + { separator: true as const }, + { label: p.value.alignLeft, shortcut: '⌥A' }, + { label: p.value.alignCenter, shortcut: '⌥H' }, + { label: p.value.alignRight, shortcut: '⌥D' }, + { separator: true as const }, + { label: p.value.alignTop, shortcut: '⌥W' }, + { label: p.value.alignMiddle, shortcut: '⌥V' }, + { label: p.value.alignBottom, shortcut: '⌥S' } + ] + }, + { + label: t.value.text, + items: [ + { label: t.value.bold, shortcut: `${mod}B` }, + { label: t.value.italic, shortcut: `${mod}I` }, + { label: t.value.underline, shortcut: `${mod}U` } + ] } - ] - - const topMenus = computed(() => [ - { label: 'File', items: fileMenu }, - ...appMenu.value.map((menu) => { - if (menu.label === 'View') { - return { - label: 'View', - items: [ - commandMenuItem('view.zoom100', `${mod}0`), - commandMenuItem('view.zoomFit', `${mod}1`), - commandMenuItem('view.zoomSelection', `${mod}2`), - { - label: 'Zoom in', - shortcut: `${mod}=`, - action: () => store.applyZoom(-100, window.innerWidth / 2, window.innerHeight / 2) - }, - { - label: 'Zoom out', - shortcut: `${mod}-`, - action: () => store.applyZoom(100, window.innerWidth / 2, window.innerHeight / 2) - }, - { separator: true as const }, - { - label: 'Performance profiler', - get checked() { - return store.renderer?.profiler.hudVisible ?? false - }, - onCheckedChange: () => { - store.toggleProfiler() - } - } - ] - } - } - - if (menu.label === 'Edit') { - return { - label: 'Edit', - items: [ - commandMenuItem('edit.undo', `${mod}Z`), - commandMenuItem('edit.redo', `${mod}⇧Z`), - { separator: true as const }, - { label: 'Copy', shortcut: `${mod}C` }, - { label: 'Paste', shortcut: `${mod}V` }, - commandMenuItem('selection.duplicate', `${mod}D`), - commandMenuItem('selection.delete', '⌫'), - { separator: true as const }, - commandMenuItem('selection.selectAll', `${mod}A`) - ] - } - } - - if (menu.label === 'Object') { - return { - label: 'Object', - items: [ - commandMenuItem('selection.group', `${mod}G`), - commandMenuItem('selection.ungroup', `${mod}⇧G`), - { separator: true as const }, - commandMenuItem('selection.createComponent', `${mod}⌥K`), - commandMenuItem('selection.createComponentSet'), - commandMenuItem('selection.detachInstance'), - { separator: true as const }, - commandMenuItem('selection.bringToFront', ']'), - commandMenuItem('selection.sendToBack', '[') - ] - } - } - - if (menu.label === 'Arrange') { - return { - label: 'Arrange', - items: [ - commandMenuItem('selection.wrapInAutoLayout', '⇧A'), - { separator: true as const }, - { label: 'Align left', shortcut: '⌥A' }, - { label: 'Align center', shortcut: '⌥H' }, - { label: 'Align right', shortcut: '⌥D' }, - { separator: true as const }, - { label: 'Align top', shortcut: '⌥W' }, - { label: 'Align middle', shortcut: '⌥V' }, - { label: 'Align bottom', shortcut: '⌥S' } - ] - } - } - - return menu - }), - { label: 'Text', items: textMenu } ]) - return { - topMenus - } + return { topMenus } } From ea4676d991e99f19b2c7efafe96032b9472da86f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 25 Mar 2026 17:39:47 +0300 Subject: [PATCH 03/14] 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. --- packages/vue/src/commands/useMenuModel.ts | 4 +- packages/vue/src/i18n/i18n.ts | 12 +++- packages/vue/src/i18n/messages.ts | 8 +++ packages/vue/src/locales/de.json | 8 +++ packages/vue/src/locales/es.json | 8 +++ packages/vue/src/locales/fr.json | 8 +++ packages/vue/src/locales/it.json | 8 +++ packages/vue/src/locales/pl.json | 8 +++ packages/vue/src/locales/ru.json | 8 +++ src/components/AppMenu.vue | 5 +- src/components/LayerTree.vue | 13 +++- src/components/Toolbar.vue | 78 +++++++++++++++-------- 12 files changed, 133 insertions(+), 35 deletions(-) diff --git a/packages/vue/src/commands/useMenuModel.ts b/packages/vue/src/commands/useMenuModel.ts index b32fd52b8..8f88f35b9 100644 --- a/packages/vue/src/commands/useMenuModel.ts +++ b/packages/vue/src/commands/useMenuModel.ts @@ -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 { diff --git a/packages/vue/src/i18n/i18n.ts b/packages/vue/src/i18n/i18n.ts index 099401253..329df72f0 100644 --- a/packages/vue/src/i18n/i18n.ts +++ b/packages/vue/src/i18n/i18n.ts @@ -5,10 +5,20 @@ import { locale } from './locale' import type { Locale } from './locale' import type { ComponentsJSON } from '@nanostores/i18n' +const localeLoaders: Record, () => 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, { 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 } }) diff --git a/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts index b34959d8d..3dcac5dc7 100644 --- a/packages/vue/src/i18n/messages.ts +++ b/packages/vue/src/i18n/messages.ts @@ -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', diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json index 5eb60cb0d..6a26f0139 100644 --- a/packages/vue/src/locales/de.json +++ b/packages/vue/src/locales/de.json @@ -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" diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json index a630fc279..bf1661dce 100644 --- a/packages/vue/src/locales/es.json +++ b/packages/vue/src/locales/es.json @@ -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" diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json index fd6637b7d..27e528f3e 100644 --- a/packages/vue/src/locales/fr.json +++ b/packages/vue/src/locales/fr.json @@ -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é" diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json index c33846614..39cce151a 100644 --- a/packages/vue/src/locales/it.json +++ b/packages/vue/src/locales/it.json @@ -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" diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json index 18b9f10d4..6bc344547 100644 --- a/packages/vue/src/locales/pl.json +++ b/packages/vue/src/locales/pl.json @@ -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" diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index f04d26f04..5870fd2a5 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -19,6 +19,14 @@ "profiler": "Профилирование", "moveToPage": "Переместить на страницу", "createInstance": "Создать экземпляр", + "hide": "Скрыть", + "show": "Показать", + "lock": "Заблокировать", + "unlock": "Разблокировать", + "cut": "Вырезать", + "front": "Вперёд", + "back": "Назад", + "toggleUi": "Переключить интерфейс", "bold": "Жирный", "italic": "Курсив", "underline": "Подчёркнутый" diff --git a/src/components/AppMenu.vue b/src/components/AppMenu.vue index 8ab8fa489..1fe7b7f0d 100644 --- a/src/components/AppMenu.vue +++ b/src/components/AppMenu.vue @@ -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 }} - +