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.
This commit is contained in:
Danila Poyarkov 2026-03-25 16:17:03 +03:00
parent 1f851e29cd
commit 2b3219e106
14 changed files with 392 additions and 22 deletions

View file

@ -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=="],

View file

@ -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
)
)
}

View file

@ -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) {

View file

@ -371,9 +371,10 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
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,

View file

@ -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"
}
}

View file

@ -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, 'en'>(locale, {
baseLocale: 'en',
async get(code) {
const mod = (await import(`../locales/${code}.json`)) as { default: ComponentsJSON }
return mod.default
}
})

View file

@ -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'

View file

@ -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<Locale | undefined>(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)
}
}

View file

@ -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'
})

View file

@ -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
* <script setup>
* const { menu, commands, locale, setLocale } = useI18n()
* </script>
*
* <template>
* <button>{{ menu.save }}</button>
* <span>{{ commands.undo }}</span>
* </template>
* ```
*/
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<Locale>,
availableLocales: AVAILABLE_LOCALES,
setLocale
}
}

View file

@ -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'

View file

@ -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": "Ничего не найдено"
}
}

View file

@ -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"]

View file

@ -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