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