Merge branch 'up_master' into feat/vector-editor

# Conflicts:
#	CHANGELOG.md
#	src/composables/use-keyboard.ts
This commit is contained in:
Ilya Nikitin 2026-03-26 02:45:30 +05:00
commit 6ad25297df
48 changed files with 1900 additions and 309 deletions

View file

@ -9,6 +9,8 @@
- Nested container navigation — each double-click goes one level deeper
- Dashed border around entered container for visual feedback
- Layer panel click syncs canvas scope automatically
- Vue SDK internationalization primitives — `useI18n()`, locale detection, persisted locale selection, lazy-loaded locale JSON files, and exported locale metadata for custom editor shells
- App language picker in the menu bar — switch UI locale without reloading
- Added a vector curve editor and improved drawing experience with the pen tool
- Resume pen drawing from existing open path endpoints — click an endpoint to continue the curve
- Close open paths by dragging one endpoint to the other
@ -16,6 +18,13 @@
### Fixes
- Fix imported `.fig` file open and page-switch regressions — loaded documents now keep graph/store state in sync, remap imported canvas/page children correctly, and recompute imported auto-layout descendants when switching pages
- Fix first canvas render happening before fonts load — wait for fonts before the initial draw to avoid Safari and text measurement glitches
- Preserve `fig-kiwi` version on `.fig` roundtrip — imports keep the original header version instead of rewriting everything to a hardcoded value; new files default to version 101
- Normalize auto-layout text export for Figma — text children inside auto-layout frames now serialize with `NONE` auto-resize to match Figma behavior and avoid overflow on reimport
- Fix keyboard editing regressions after the refactor — canvas shortcuts no longer fire while editing text, and Delete/Backspace no longer delete nodes during text entry
- Fix MCP page switching persistence — `switch_page` now survives across tool calls in the same session
- Improve CJK font fallback coverage — load multiple Google Fonts for broader Han/Japanese/Korean text support
- Normalize more visible UI strings for localized app chrome — menus, panels, variables dialog, code panel, chat setup, and editor controls now respect the selected locale instead of falling back to English in common flows
- Fix imported text rendering in browser and headless export — preserve stored bounds until fonts are ready, restore missing font-loaded guards, use natural width for `WIDTH_AND_HEIGHT` text, and clip text to node bounds
- Fix browser/headless rendering mismatch for imported toolbar/instance content by correcting runtime imported layout recomputation instead of diverging browser rendering behavior
- Fix `set_layout` tool not defaulting to HUG sizing when enabling auto-layout — frames now shrink/grow to fit children instead of keeping fixed dimensions

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

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

@ -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<EditorCommandId, EditorCommand> = {
'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()
}

View file

@ -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<MenuEntry[]>(() => [
commandMenuItem('edit.undo'),
commandMenuItem('edit.redo'),
@ -68,10 +72,10 @@ export function useMenuModel() {
const arrangeMenu = computed<MenuEntry[]>(() => [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<MenuEntry[]>(() => {
@ -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
@ -120,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 {

23
packages/vue/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,23 @@
interface FontData {
family: string
fullName: string
postscriptName: string
style: string
blob(): Promise<Blob>
}
interface Window {
queryLocalFonts?(): Promise<FontData[]>
}
interface GestureEvent extends UIEvent {
scale: number
rotation: number
clientX: number
clientY: number
}
declare module '*?raw' {
const content: string
export default content
}

View file

@ -0,0 +1,24 @@
import { createI18n } from '@nanostores/i18n'
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) {
if (code === 'en') return {}
const mod = await localeLoaders[code]()
return mod.default
}
})

View file

@ -0,0 +1,11 @@
export { useI18n } from './useI18n'
export { locale, localeSetting, setLocale, AVAILABLE_LOCALES, LOCALE_LABELS } from './locale'
export type { Locale } from './locale'
export {
menuMessages,
commandMessages,
toolMessages,
panelMessages,
pageMessages,
dialogMessages
} from './messages'

View file

@ -0,0 +1,35 @@
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 LOCALE_LABELS: Record<Locale, string> = {
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
it: 'Italiano',
pl: 'Polski',
ru: 'Русский'
}
const LOCALE_STORAGE_KEY = 'open-pencil-locale'
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(LOCALE_STORAGE_KEY, code)
}
}
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem(LOCALE_STORAGE_KEY) as Locale | null
if (saved && AVAILABLE_LOCALES.includes(saved)) {
localeSetting.set(saved)
}
}

View file

@ -0,0 +1,195 @@
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',
language: 'Language',
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',
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',
pages: 'Pages',
design: 'Design',
code: 'Code',
ai: 'AI',
assets: 'Assets',
page: 'Page',
position: 'Position',
layout: 'Layout',
autoLayout: 'Auto layout',
alignment: 'Alignment',
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',
noLocalVariables: 'No local variables',
openVariables: 'Open variables',
addPage: 'Add page',
toggleVisibility: 'Toggle visibility',
independentCornerRadii: 'Independent corner radii',
detachVariable: 'Detach variable',
applyVariable: 'Apply variable',
noVariablesFound: 'No variables found',
addAutoLayout: 'Add auto layout (Shift+A)',
removeAutoLayout: 'Remove auto layout',
alignLeft: 'Align left',
alignCenterHorizontally: 'Align center horizontally',
alignRight: 'Align right',
alignTop: 'Align top',
alignCenterVertically: 'Align center vertically',
alignBottom: 'Align bottom',
flipHorizontal: 'Flip horizontal',
flipVertical: 'Flip vertical',
rotate90: 'Rotate 90°',
mixedFillsHelp: 'Click + to replace mixed fills',
mixedStrokesHelp: 'Click + to replace mixed strokes',
mixedEffectsHelp: 'Click + to replace mixed effects',
strokeSides: 'Stroke sides',
mixed: 'Mixed',
layersCount: params('{count} layers'),
goToMainComponent: 'Go to Main Component',
detachInstance: 'Detach Instance',
solid: 'Solid',
linearGradient: 'Linear',
radialGradient: 'Radial',
image: 'Image',
stops: 'Stops',
addStop: 'Add stop',
alignCenter: 'Align center',
alignMiddle: 'Align middle'
})
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',
copy: 'Copy',
copied: 'Copied',
createCollection: 'Create collection',
localVariables: 'Local variables',
noVariableCollections: 'No variable collections',
selectLayerForJSX: 'Select a layer to see its JSX code',
connectAIProvider: 'Connect an AI provider to start chatting.',
connect: 'Connect',
getAPIKey: params('Get an {provider} API key →'),
oneKeyManyModels: 'One key for 100+ models from all providers.',
describeChange: 'Describe a change…',
describeCreateOrChange: 'Describe what you want to create or change.',
stopGenerating: 'Stop generating',
sendMessage: 'Send message',
baseURLPlaceholder: 'Base URL (e.g. http://localhost:11434/v1)',
modelIDPlaceholder: 'Model ID (e.g. llama-3.3-70b)',
yourName: 'Your name',
enterYourName: 'Enter your name',
shareThisFile: 'Share this file',
joinRoom: 'Join room',
join: 'Join',
roomLink: 'Room link',
joinCollaboration: 'Join collaboration',
orJoinRoom: 'or join a room',
pasteRoomLinkOrId: 'Paste room link or ID',
connected: 'Connected',
search: 'Search…',
noResults: 'No results',
share: 'Share'
})

View file

@ -0,0 +1,47 @@
import { useStore } from '@nanostores/vue'
import { locale, setLocale, AVAILABLE_LOCALES, LOCALE_LABELS } 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,
localeLabels: LOCALE_LABELS,
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, LOCALE_LABELS } from './i18n'
export type { Locale } from './i18n'
export {
menuMessages,
commandMessages,
toolMessages,
panelMessages,
pageMessages,
dialogMessages
} from './i18n'

View file

@ -0,0 +1,177 @@
{
"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",
"language": "Sprache",
"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"
},
"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",
"pages": "Seiten",
"design": "Design",
"code": "Code",
"ai": "KI",
"assets": "Elemente",
"page": "Seite",
"position": "Position",
"layout": "Layout",
"autoLayout": "Auto-Layout",
"alignment": "Ausrichtung",
"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",
"noLocalVariables": "Keine lokalen Variablen",
"openVariables": "Variablen öffnen",
"addPage": "Seite hinzufügen",
"toggleVisibility": "Sichtbarkeit umschalten",
"independentCornerRadii": "Unabhängige Eckenradien",
"detachVariable": "Variable lösen",
"applyVariable": "Variable anwenden",
"noVariablesFound": "Keine Variablen gefunden",
"addAutoLayout": "Auto-Layout hinzufügen (Shift+A)",
"removeAutoLayout": "Auto-Layout entfernen",
"mixed": "Gemischt",
"layersCount": "{count} Ebenen",
"goToMainComponent": "Zur Hauptkomponente",
"detachInstance": "Instanz lösen",
"solid": "Vollton",
"linearGradient": "Linear",
"radialGradient": "Radial",
"image": "Bild",
"stops": "Stopps",
"addStop": "Stopp hinzufügen",
"alignLeft": "Links ausrichten",
"alignCenter": "Zentrieren",
"alignRight": "Rechts ausrichten",
"alignTop": "Oben ausrichten",
"alignMiddle": "Mittig ausrichten",
"alignBottom": "Unten ausrichten",
"alignCenterHorizontally": "Horizontal zentrieren",
"alignCenterVertically": "Vertikal zentrieren",
"flipHorizontal": "Horizontal spiegeln",
"flipVertical": "Vertikal spiegeln",
"rotate90": "90° drehen",
"mixedFillsHelp": "Klicke auf +, um gemischte Füllungen zu ersetzen",
"mixedStrokesHelp": "Klicke auf +, um gemischte Konturen zu ersetzen",
"mixedEffectsHelp": "Klicke auf +, um gemischte Effekte zu ersetzen",
"strokeSides": "Konturseiten"
},
"pages": {
"newPage": "Neue Seite",
"rename": "Umbenennen",
"delete": "Löschen",
"pageName": "Seite {number}"
},
"dialogs": {
"cancel": "Abbrechen",
"apply": "Anwenden",
"close": "Schließen",
"ok": "OK",
"copy": "Kopieren",
"copied": "Kopiert",
"createCollection": "Sammlung erstellen",
"localVariables": "Lokale Variablen",
"noVariableCollections": "Keine Variablensammlungen",
"selectLayerForJSX": "Wähle eine Ebene, um ihren JSX-Code zu sehen",
"connectAIProvider": "Verbinde einen KI-Anbieter, um den Chat zu starten.",
"connect": "Verbinden",
"getAPIKey": "Einen {provider}-API-Schlüssel erhalten →",
"oneKeyManyModels": "Ein Schlüssel für über 100 Modelle aller Anbieter.",
"describeChange": "Beschreibe eine Änderung…",
"describeCreateOrChange": "Beschreibe, was du erstellen oder ändern möchtest.",
"stopGenerating": "Generierung stoppen",
"sendMessage": "Nachricht senden",
"baseURLPlaceholder": "Base URL (z. B. http://localhost:11434/v1)",
"modelIDPlaceholder": "Model ID (z. B. llama-3.3-70b)",
"yourName": "Dein Name",
"enterYourName": "Deinen Namen eingeben",
"shareThisFile": "Diese Datei teilen",
"joinRoom": "Raum beitreten",
"join": "Beitreten",
"roomLink": "Raumlink",
"joinCollaboration": "Zur Zusammenarbeit beitreten",
"orJoinRoom": "oder einem Raum beitreten",
"pasteRoomLinkOrId": "Raumlink oder ID einfügen",
"connected": "Verbunden",
"search": "Suchen…",
"noResults": "Keine Ergebnisse",
"share": "Teilen"
}
}

View file

@ -0,0 +1,177 @@
{
"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",
"language": "Idioma",
"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"
},
"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",
"pages": "Páginas",
"design": "Diseño",
"code": "Código",
"ai": "IA",
"assets": "Recursos",
"page": "Página",
"position": "Posición",
"layout": "Diseño",
"autoLayout": "Auto-layout",
"alignment": "Alineación",
"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",
"noLocalVariables": "Sin variables locales",
"openVariables": "Abrir variables",
"addPage": "Añadir página",
"toggleVisibility": "Alternar visibilidad",
"independentCornerRadii": "Radios de esquina independientes",
"detachVariable": "Desvincular variable",
"applyVariable": "Aplicar variable",
"noVariablesFound": "No se encontraron variables",
"addAutoLayout": "Añadir auto-layout (Shift+A)",
"removeAutoLayout": "Quitar auto-layout",
"mixed": "Mixto",
"layersCount": "{count} capas",
"goToMainComponent": "Ir al componente principal",
"detachInstance": "Separar instancia",
"solid": "Sólido",
"linearGradient": "Lineal",
"radialGradient": "Radial",
"image": "Imagen",
"stops": "Paradas",
"addStop": "Añadir parada",
"alignLeft": "Alinear a la izquierda",
"alignCenter": "Centrar",
"alignRight": "Alinear a la derecha",
"alignTop": "Alinear arriba",
"alignMiddle": "Centrar verticalmente",
"alignBottom": "Alinear abajo",
"alignCenterHorizontally": "Centrar horizontalmente",
"alignCenterVertically": "Centrar verticalmente",
"flipHorizontal": "Voltear horizontalmente",
"flipVertical": "Voltear verticalmente",
"rotate90": "Girar 90°",
"mixedFillsHelp": "Haz clic en + para reemplazar rellenos mixtos",
"mixedStrokesHelp": "Haz clic en + para reemplazar trazos mixtos",
"mixedEffectsHelp": "Haz clic en + para reemplazar efectos mixtos",
"strokeSides": "Lados del trazo"
},
"pages": {
"newPage": "Nueva página",
"rename": "Renombrar",
"delete": "Eliminar",
"pageName": "Página {number}"
},
"dialogs": {
"cancel": "Cancelar",
"apply": "Aplicar",
"close": "Cerrar",
"ok": "Aceptar",
"copy": "Copiar",
"copied": "Copiado",
"createCollection": "Crear colección",
"localVariables": "Variables locales",
"noVariableCollections": "No hay colecciones de variables",
"selectLayerForJSX": "Selecciona una capa para ver su código JSX",
"connectAIProvider": "Conecta un proveedor de IA para empezar a chatear.",
"connect": "Conectar",
"getAPIKey": "Obtener una clave API de {provider} →",
"oneKeyManyModels": "Una clave para más de 100 modelos de todos los proveedores.",
"describeChange": "Describe un cambio…",
"describeCreateOrChange": "Describe lo que quieres crear o cambiar.",
"stopGenerating": "Detener generación",
"sendMessage": "Enviar mensaje",
"baseURLPlaceholder": "Base URL (p. ej. http://localhost:11434/v1)",
"modelIDPlaceholder": "Model ID (p. ej. llama-3.3-70b)",
"yourName": "Tu nombre",
"enterYourName": "Introduce tu nombre",
"shareThisFile": "Compartir este archivo",
"joinRoom": "Unirse a la sala",
"join": "Unirse",
"roomLink": "Enlace de la sala",
"joinCollaboration": "Unirse a la colaboración",
"orJoinRoom": "o unirse a una sala",
"pasteRoomLinkOrId": "Pega el enlace o ID de la sala",
"connected": "Conectado",
"search": "Buscar…",
"noResults": "Sin resultados",
"share": "Compartir"
}
}

View file

@ -0,0 +1,177 @@
{
"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",
"language": "Langue",
"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é"
},
"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",
"pages": "Pages",
"design": "Design",
"code": "Code",
"ai": "IA",
"assets": "Ressources",
"page": "Page",
"position": "Position",
"layout": "Disposition",
"autoLayout": "Auto-layout",
"alignment": "Alignement",
"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",
"noLocalVariables": "Aucune variable locale",
"openVariables": "Ouvrir les variables",
"addPage": "Ajouter une page",
"toggleVisibility": "Basculer la visibilité",
"independentCornerRadii": "Rayons de coin indépendants",
"detachVariable": "Détacher la variable",
"applyVariable": "Appliquer la variable",
"noVariablesFound": "Aucune variable trouvée",
"addAutoLayout": "Ajouter un auto-layout (Shift+A)",
"removeAutoLayout": "Retirer l'auto-layout",
"mixed": "Mixte",
"layersCount": "{count} calques",
"goToMainComponent": "Aller au composant principal",
"detachInstance": "Détacher l'instance",
"solid": "Uni",
"linearGradient": "Linéaire",
"radialGradient": "Radial",
"image": "Image",
"stops": "Arrêts",
"addStop": "Ajouter un arrêt",
"alignLeft": "Aligner à gauche",
"alignCenter": "Centrer",
"alignRight": "Aligner à droite",
"alignTop": "Aligner en haut",
"alignMiddle": "Centrer verticalement",
"alignBottom": "Aligner en bas",
"alignCenterHorizontally": "Centrer horizontalement",
"alignCenterVertically": "Centrer verticalement",
"flipHorizontal": "Retourner horizontalement",
"flipVertical": "Retourner verticalement",
"rotate90": "Pivoter de 90°",
"mixedFillsHelp": "Cliquez sur + pour remplacer les remplissages mixtes",
"mixedStrokesHelp": "Cliquez sur + pour remplacer les contours mixtes",
"mixedEffectsHelp": "Cliquez sur + pour remplacer les effets mixtes",
"strokeSides": "Côtés du contour"
},
"pages": {
"newPage": "Nouvelle page",
"rename": "Renommer",
"delete": "Supprimer",
"pageName": "Page {number}"
},
"dialogs": {
"cancel": "Annuler",
"apply": "Appliquer",
"close": "Fermer",
"ok": "OK",
"copy": "Copier",
"copied": "Copié",
"createCollection": "Créer une collection",
"localVariables": "Variables locales",
"noVariableCollections": "Aucune collection de variables",
"selectLayerForJSX": "Sélectionnez un calque pour voir son code JSX",
"connectAIProvider": "Connectez un fournisseur d’IA pour commencer à discuter.",
"connect": "Connecter",
"getAPIKey": "Obtenir une clé API {provider} →",
"oneKeyManyModels": "Une clé pour plus de 100 modèles de tous les fournisseurs.",
"describeChange": "Décrivez un changement…",
"describeCreateOrChange": "Décrivez ce que vous voulez créer ou modifier.",
"stopGenerating": "Arrêter la génération",
"sendMessage": "Envoyer le message",
"baseURLPlaceholder": "Base URL (ex. http://localhost:11434/v1)",
"modelIDPlaceholder": "Model ID (ex. llama-3.3-70b)",
"yourName": "Votre nom",
"enterYourName": "Entrez votre nom",
"shareThisFile": "Partager ce fichier",
"joinRoom": "Rejoindre la salle",
"join": "Rejoindre",
"roomLink": "Lien de la salle",
"joinCollaboration": "Rejoindre la collaboration",
"orJoinRoom": "ou rejoindre une salle",
"pasteRoomLinkOrId": "Collez le lien ou l’ID de la salle",
"connected": "Connecté",
"search": "Rechercher…",
"noResults": "Aucun résultat",
"share": "Partager"
}
}

View file

@ -0,0 +1,177 @@
{
"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",
"language": "Lingua",
"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"
},
"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",
"pages": "Pagine",
"design": "Design",
"code": "Codice",
"ai": "AI",
"assets": "Risorse",
"page": "Pagina",
"position": "Posizione",
"layout": "Layout",
"autoLayout": "Auto-layout",
"alignment": "Allineamento",
"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",
"noLocalVariables": "Nessuna variabile locale",
"openVariables": "Apri variabili",
"addPage": "Aggiungi pagina",
"toggleVisibility": "Attiva/disattiva visibilità",
"independentCornerRadii": "Raggi angolo indipendenti",
"detachVariable": "Scollega variabile",
"applyVariable": "Applica variabile",
"noVariablesFound": "Nessuna variabile trovata",
"addAutoLayout": "Aggiungi auto-layout (Shift+A)",
"removeAutoLayout": "Rimuovi auto-layout",
"mixed": "Misto",
"layersCount": "{count} livelli",
"goToMainComponent": "Vai al componente principale",
"detachInstance": "Scollega istanza",
"solid": "Pieno",
"linearGradient": "Lineare",
"radialGradient": "Radiale",
"image": "Immagine",
"stops": "Punti",
"addStop": "Aggiungi punto",
"alignLeft": "Allinea a sinistra",
"alignCenter": "Centra",
"alignRight": "Allinea a destra",
"alignTop": "Allinea in alto",
"alignMiddle": "Centra verticalmente",
"alignBottom": "Allinea in basso",
"alignCenterHorizontally": "Centra orizzontalmente",
"alignCenterVertically": "Centra verticalmente",
"flipHorizontal": "Rifletti orizzontalmente",
"flipVertical": "Rifletti verticalmente",
"rotate90": "Ruota di 90°",
"mixedFillsHelp": "Fai clic su + per sostituire i riempimenti misti",
"mixedStrokesHelp": "Fai clic su + per sostituire i contorni misti",
"mixedEffectsHelp": "Fai clic su + per sostituire gli effetti misti",
"strokeSides": "Lati del contorno"
},
"pages": {
"newPage": "Nuova pagina",
"rename": "Rinomina",
"delete": "Elimina",
"pageName": "Pagina {number}"
},
"dialogs": {
"cancel": "Annulla",
"apply": "Applica",
"close": "Chiudi",
"ok": "OK",
"copy": "Copia",
"copied": "Copiato",
"createCollection": "Crea raccolta",
"localVariables": "Variabili locali",
"noVariableCollections": "Nessuna raccolta di variabili",
"selectLayerForJSX": "Seleziona un livello per vedere il suo codice JSX",
"connectAIProvider": "Collega un provider AI per iniziare a chattare.",
"connect": "Connetti",
"getAPIKey": "Ottieni una chiave API {provider} →",
"oneKeyManyModels": "Una chiave per oltre 100 modelli di tutti i provider.",
"describeChange": "Descrivi una modifica…",
"describeCreateOrChange": "Descrivi cosa vuoi creare o cambiare.",
"stopGenerating": "Interrompi generazione",
"sendMessage": "Invia messaggio",
"baseURLPlaceholder": "Base URL (es. http://localhost:11434/v1)",
"modelIDPlaceholder": "Model ID (es. llama-3.3-70b)",
"yourName": "Il tuo nome",
"enterYourName": "Inserisci il tuo nome",
"shareThisFile": "Condividi questo file",
"joinRoom": "Entra nella stanza",
"join": "Entra",
"roomLink": "Link della stanza",
"joinCollaboration": "Partecipa alla collaborazione",
"orJoinRoom": "o entra in una stanza",
"pasteRoomLinkOrId": "Incolla il link o l'ID della stanza",
"connected": "Connesso",
"search": "Cerca…",
"noResults": "Nessun risultato",
"share": "Condividi"
}
}

View file

@ -0,0 +1,177 @@
{
"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",
"language": "Język",
"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"
},
"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",
"pages": "Strony",
"design": "Design",
"code": "Kod",
"ai": "AI",
"assets": "Zasoby",
"page": "Strona",
"position": "Pozycja",
"layout": "Układ",
"autoLayout": "Auto-layout",
"alignment": "Wyrównanie",
"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",
"noLocalVariables": "Brak lokalnych zmiennych",
"openVariables": "Otwórz zmienne",
"addPage": "Dodaj stronę",
"toggleVisibility": "Przełącz widoczność",
"independentCornerRadii": "Niezależne promienie rogów",
"detachVariable": "Odłącz zmienną",
"applyVariable": "Zastosuj zmienną",
"noVariablesFound": "Nie znaleziono zmiennych",
"addAutoLayout": "Dodaj auto-layout (Shift+A)",
"removeAutoLayout": "Usuń auto-layout",
"mixed": "Mieszane",
"layersCount": "{count} warstw",
"goToMainComponent": "Przejdź do głównego komponentu",
"detachInstance": "Odłącz instancję",
"solid": "Jednolity",
"linearGradient": "Liniowy",
"radialGradient": "Promieniowy",
"image": "Obraz",
"stops": "Punkty",
"addStop": "Dodaj punkt",
"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",
"alignCenterHorizontally": "Wyśrodkuj poziomo",
"alignCenterVertically": "Wyśrodkuj pionowo",
"flipHorizontal": "Odbij poziomo",
"flipVertical": "Odbij pionowo",
"rotate90": "Obróć o 90°",
"mixedFillsHelp": "Kliknij +, aby zastąpić mieszane wypełnienia",
"mixedStrokesHelp": "Kliknij +, aby zastąpić mieszane obrysy",
"mixedEffectsHelp": "Kliknij +, aby zastąpić mieszane efekty",
"strokeSides": "Boki obrysu"
},
"pages": {
"newPage": "Nowa strona",
"rename": "Zmień nazwę",
"delete": "Usuń",
"pageName": "Strona {number}"
},
"dialogs": {
"cancel": "Anuluj",
"apply": "Zastosuj",
"close": "Zamknij",
"ok": "OK",
"copy": "Kopiuj",
"copied": "Skopiowano",
"createCollection": "Utwórz kolekcję",
"localVariables": "Zmienne lokalne",
"noVariableCollections": "Brak kolekcji zmiennych",
"selectLayerForJSX": "Wybierz warstwę, aby zobaczyć jej kod JSX",
"connectAIProvider": "Połącz dostawcę AI, aby rozpocząć czat.",
"connect": "Połącz",
"getAPIKey": "Pobierz klucz API {provider} →",
"oneKeyManyModels": "Jeden klucz do ponad 100 modeli od wszystkich dostawców.",
"describeChange": "Opisz zmianę…",
"describeCreateOrChange": "Opisz, co chcesz utworzyć lub zmienić.",
"stopGenerating": "Zatrzymaj generowanie",
"sendMessage": "Wyślij wiadomość",
"baseURLPlaceholder": "Base URL (np. http://localhost:11434/v1)",
"modelIDPlaceholder": "Model ID (np. llama-3.3-70b)",
"yourName": "Twoje imię",
"enterYourName": "Wpisz swoje imię",
"shareThisFile": "Udostępnij ten plik",
"joinRoom": "Dołącz do pokoju",
"join": "Dołącz",
"roomLink": "Link do pokoju",
"joinCollaboration": "Dołącz do współpracy",
"orJoinRoom": "lub dołącz do pokoju",
"pasteRoomLinkOrId": "Wklej link lub ID pokoju",
"connected": "Połączono",
"search": "Szukaj…",
"noResults": "Brak wyników",
"share": "Udostępnij"
}
}

View file

@ -0,0 +1,181 @@
{
"menu": {
"file": "Файл",
"edit": "Правка",
"view": "Вид",
"object": "Объект",
"arrange": "Порядок",
"text": "Текст",
"new": "Создать",
"open": "Открыть…",
"save": "Сохранить",
"saveAs": "Сохранить как…",
"exportSelection": "Экспорт выделения…",
"autosave": "Автосохранение в файл",
"copy": "Копировать",
"paste": "Вставить",
"zoomIn": "Увеличить",
"zoomOut": "Уменьшить",
"profiler": "Профилирование",
"language": "Язык",
"moveToPage": "Переместить на страницу",
"createInstance": "Создать экземпляр",
"hide": "Скрыть",
"show": "Показать",
"lock": "Заблокировать",
"unlock": "Разблокировать",
"cut": "Вырезать",
"front": "Вперёд",
"back": "Назад",
"toggleUI": "Переключить интерфейс",
"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": "Слои",
"pages": "Страницы",
"design": "Дизайн",
"code": "Код",
"ai": "AI",
"assets": "Ассеты",
"page": "Страница",
"position": "Позиция",
"layout": "Раскладка",
"autoLayout": "Автораскладка",
"alignment": "Выравнивание",
"appearance": "Внешний вид",
"fill": "Заливка",
"stroke": "Обводка",
"effects": "Эффекты",
"export": "Экспорт",
"typography": "Типографика",
"variables": "Переменные",
"constraints": "Ограничения",
"addFill": "Добавить заливку",
"addStroke": "Добавить обводку",
"addEffect": "Добавить эффект",
"addExport": "Добавить экспорт",
"dropShadow": "Тень",
"innerShadow": "Внутренняя тень",
"layerBlur": "Размытие слоя",
"backgroundBlur": "Размытие фона",
"noSelection": "Ничего не выделено",
"noLocalVariables": "Нет локальных переменных",
"openVariables": "Открыть переменные",
"addPage": "Добавить страницу",
"toggleVisibility": "Переключить видимость",
"independentCornerRadii": "Независимые радиусы углов",
"detachVariable": "Отвязать переменную",
"applyVariable": "Применить переменную",
"noVariablesFound": "Переменные не найдены",
"addAutoLayout": "Добавить автораскладку (Shift+A)",
"removeAutoLayout": "Убрать автораскладку",
"alignLeft": "Выровнять по левому краю",
"alignCenterHorizontally": "Выровнять по центру по горизонтали",
"alignRight": "Выровнять по правому краю",
"alignTop": "Выровнять по верхнему краю",
"alignCenterVertically": "Выровнять по центру по вертикали",
"alignBottom": "Выровнять по нижнему краю",
"flipHorizontal": "Отразить по горизонтали",
"flipVertical": "Отразить по вертикали",
"rotate90": "Повернуть на 90°",
"mixedFillsHelp": "Нажмите +, чтобы заменить смешанные заливки",
"mixedStrokesHelp": "Нажмите +, чтобы заменить смешанные обводки",
"mixedEffectsHelp": "Нажмите +, чтобы заменить смешанные эффекты",
"strokeSides": "Стороны обводки",
"mixed": "Смешанное",
"layersCount": "{count} слоёв",
"goToMainComponent": "Перейти к главному компоненту",
"detachInstance": "Отвязать экземпляр",
"solid": "Сплошной",
"linearGradient": "Линейный",
"radialGradient": "Радиальный",
"image": "Изображение",
"stops": "Точки",
"addStop": "Добавить точку",
"alignLeft": "По левому краю",
"alignCenter": "По центру",
"alignRight": "По правому краю",
"alignTop": "По верхнему краю",
"alignMiddle": "По середине",
"alignBottom": "По нижнему краю"
},
"pages": {
"newPage": "Новая страница",
"rename": "Переименовать",
"delete": "Удалить",
"pageName": "Страница {number}"
},
"dialogs": {
"cancel": "Отмена",
"apply": "Применить",
"close": "Закрыть",
"ok": "ОК",
"copy": "Копировать",
"copied": "Скопировано",
"createCollection": "Создать коллекцию",
"localVariables": "Локальные переменные",
"noVariableCollections": "Нет коллекций переменных",
"selectLayerForJSX": "Выберите слой, чтобы увидеть его JSX-код",
"connectAIProvider": "Подключите AI-провайдера, чтобы начать чат.",
"connect": "Подключить",
"getAPIKey": "Получить API-ключ {provider} →",
"oneKeyManyModels": "Один ключ для 100+ моделей от разных провайдеров.",
"describeChange": "Опишите изменение…",
"describeCreateOrChange": "Опишите, что вы хотите создать или изменить.",
"stopGenerating": "Остановить генерацию",
"sendMessage": "Отправить сообщение",
"baseURLPlaceholder": "Base URL (например, http://localhost:11434/v1)",
"modelIDPlaceholder": "Model ID (например, llama-3.3-70b)",
"yourName": "Ваше имя",
"enterYourName": "Введите ваше имя",
"shareThisFile": "Поделиться файлом",
"joinRoom": "Войти в комнату",
"join": "Войти",
"roomLink": "Ссылка на комнату",
"joinCollaboration": "Присоединиться к совместной работе",
"orJoinRoom": "или войти в комнату",
"pasteRoomLinkOrId": "Вставьте ссылку на комнату или ID",
"connected": "Подключено",
"search": "Поиск…",
"noResults": "Ничего не найдено",
"share": "Поделиться"
}
}

View file

@ -1,8 +1,19 @@
import { onClickOutside } from '@vueuse/core'
import { nextTick, ref, type Ref } from 'vue'
export function useInlineRename<T extends string>(onCommit: (id: T, newName: string) => void) {
const editingId = ref<T | null>(null)
export interface InlineRenameState<T extends string> {
editingId: Ref<T | null>
start: (id: T, currentName: string) => void
focusInput: (input: HTMLInputElement | null) => Promise<void>
commit: (id: T, input: HTMLInputElement) => void
cancel: () => void
onKeydown: (e: KeyboardEvent) => void
}
export function useInlineRename<T extends string>(
onCommit: (id: T, newName: string) => void
): InlineRenameState<T> {
const editingId: Ref<T | null> = ref(null)
const inputRef: Ref<HTMLInputElement | null> = ref(null)
let originalName = ''
let cleanupOutsideClick: (() => void) | undefined

View file

@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "ESNext",
"target": "ES2022",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
@ -8,10 +9,18 @@
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist",
"rootDir": "./src",
"rootDir": "..",
"skipLibCheck": true,
"esModuleInterop": true
"esModuleInterop": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@open-pencil/vue": ["./src/index.ts"],
"@open-pencil/vue/*": ["./src/*"],
"@open-pencil/core": ["../core/src/index.ts"],
"@open-pencil/core/*": ["../core/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "../core/src/global.d.ts"],
"exclude": ["node_modules", "dist", "example"]
}

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"
@ -133,6 +134,17 @@ const subMenuCls = useMenuUI({ content: 'min-w-44' })
v-if="'separator' in sub && sub.separator"
:class="menuCls.separator"
/>
<MenubarCheckboxItem
v-else-if="'onCheckedChange' in sub && sub.onCheckedChange"
:model-value="'checked' in sub ? sub.checked : undefined"
:class="menuCls.item"
@update:model-value="sub.onCheckedChange?.($event as boolean)"
>
<span class="flex-1">{{ 'label' in sub ? sub.label : '' }}</span>
<MenubarItemIndicator class="text-surface">
<icon-lucide-check class="size-3.5" />
</MenubarItemIndicator>
</MenubarCheckboxItem>
<MenubarItem
v-else
:class="menuCls.item"

View file

@ -10,6 +10,7 @@ import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
import { useAIChat } from '@/composables/use-chat'
import { useI18n } from '@open-pencil/vue'
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
@ -17,6 +18,7 @@ import type { UIMessage } from 'ai'
const IS_DEV = import.meta.env.DEV
const { isConfigured, ensureChat, resetChat } = useAIChat()
const { dialogs } = useI18n()
const chat = ref<Chat<UIMessage> | null>(null)
@ -120,7 +122,7 @@ function handleClearChat() {
class="flex h-full flex-col items-center justify-center gap-3 text-muted"
>
<icon-lucide-message-circle class="size-8 opacity-50" />
<p class="text-center text-xs">Describe what you want to create or change.</p>
<p class="text-center text-xs">{{ dialogs.describeCreateOrChange }}</p>
</div>
<!-- Messages -->

View file

@ -6,7 +6,7 @@ import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'
import { selectionToJSX } from '@open-pencil/core'
import { useSceneComputed } from '@open-pencil/vue'
import { useI18n, useSceneComputed } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
@ -14,6 +14,7 @@ import type { JSXFormat } from '@open-pencil/core'
const store = useEditorStore()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
const { dialogs } = useI18n()
const jsxFormat = ref<JSXFormat>('openpencil')
function toggleFormat() {
@ -44,7 +45,7 @@ function copyCode() {
data-test-id="code-panel-empty"
class="flex flex-1 items-center justify-center px-4 text-center"
>
<span class="text-xs text-muted">Select a layer to see its JSX code</span>
<span class="text-xs text-muted">{{ dialogs.selectLayerForJSX }}</span>
</div>
<div v-else data-test-id="code-panel" class="flex min-h-0 flex-1 flex-col">
@ -69,7 +70,7 @@ function copyCode() {
>
<icon-lucide-check v-if="copied" class="size-3 text-green-400" />
<icon-lucide-copy v-else class="size-3" />
{{ copied ? 'Copied' : 'Copy' }}
{{ copied ? dialogs.copied : dialogs.copy }}
</button>
</div>

View file

@ -10,12 +10,14 @@ import { usePopoverUI } from '@/components/ui/popover'
import { DEFAULT_COLLAB_STATE, useCollabInjected } from '@/composables/use-collab'
import { toast } from '@/utils/toast'
import { initials } from '@/utils/text'
import { useI18n } from '@open-pencil/vue'
const route = useRoute()
const router = useRouter()
const cls = usePopoverUI({ content: 'z-50 w-72 p-3' })
const collab = useCollabInjected()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
const { dialogs } = useI18n()
const joinInput = ref('')
const nameDraft = ref(collab?.state.value.localName ?? '')
@ -120,7 +122,7 @@ function onDisconnect() {
"
>
<icon-lucide-share-2 class="size-3.5" />
{{ state.connected ? 'Connected' : isJoining ? 'Join room' : 'Share' }}
{{ state.connected ? dialogs.connected : isJoining ? dialogs.joinRoom : dialogs.share }}
</button>
</PopoverTrigger>
@ -134,7 +136,7 @@ function onDisconnect() {
>
<!-- Connected state -->
<template v-if="state.connected">
<div class="mb-3 text-xs font-medium text-surface">Room link</div>
<div class="mb-3 text-xs font-medium text-surface">{{ dialogs.roomLink }}</div>
<div class="mb-3 flex items-center gap-1.5">
<input
:value="shareUrl"
@ -169,18 +171,18 @@ function onDisconnect() {
<!-- Joining via /share/ link -->
<template v-else-if="isJoining">
<div class="mb-1 text-xs font-medium text-surface">Join collaboration</div>
<div class="mb-1 text-xs font-medium text-surface">{{ dialogs.joinCollaboration }}</div>
<div class="mb-3 text-[11px] text-muted">
Someone shared this file with you. Enter your name to join.
</div>
<div class="mb-3">
<label class="mb-1 block text-xs text-muted">Your name</label>
<label class="mb-1 block text-xs text-muted">{{ dialogs.yourName }}</label>
<input
v-model="nameDraft"
data-test-id="collab-name-input"
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Enter your name"
:placeholder="dialogs.enterYourName"
autofocus
@keydown.enter="onJoin"
/>
@ -200,12 +202,12 @@ function onDisconnect() {
<!-- Not connected: share or join -->
<template v-else>
<div class="mb-3">
<label class="mb-1 block text-xs text-muted">Your name</label>
<label class="mb-1 block text-xs text-muted">{{ dialogs.yourName }}</label>
<input
v-model="nameDraft"
data-test-id="collab-name-input"
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Enter your name"
:placeholder="dialogs.enterYourName"
@keydown.enter="onShare"
/>
</div>
@ -217,12 +219,12 @@ function onDisconnect() {
@click="onShare"
>
<icon-lucide-share-2 class="size-3.5" />
Share this file
{{ dialogs.shareThisFile }}
</button>
<div class="mb-2 flex items-center gap-2">
<div class="h-px flex-1 bg-border" />
<span class="text-[11px] text-muted">or join a room</span>
<span class="text-[11px] text-muted">{{ dialogs.orJoinRoom }}</span>
<div class="h-px flex-1 bg-border" />
</div>
@ -231,7 +233,7 @@ function onDisconnect() {
v-model="joinInput"
data-test-id="collab-join-input"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Paste room link or ID"
:placeholder="dialogs.pasteRoomLinkOrId"
@keydown.enter="onJoin"
/>
<button

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useSelectionState, useEditorCommands } from '@open-pencil/vue'
import { useI18n, useSelectionState, useEditorCommands } from '@open-pencil/vue'
import VariablesDialog from './VariablesDialog.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
@ -24,6 +24,7 @@ const isComponentType = computed(() => {
const t = node.value?.type
return t === 'COMPONENT' || t === 'COMPONENT_SET' || t === 'INSTANCE'
})
const { panels } = useI18n()
</script>
<template>
@ -37,8 +38,8 @@ const isComponentType = computed(() => {
data-test-id="design-multi-header"
class="flex items-center gap-1.5 border-b border-border px-3 py-2"
>
<span class="text-[11px] text-muted">Mixed</span>
<span class="text-xs font-semibold">{{ multiCount }} layers</span>
<span class="text-[11px] text-muted">{{ panels.mixed }}</span>
<span class="text-xs font-semibold">{{ panels.layersCount({ count: String(multiCount) }) }}</span>
</div>
<PositionSection />
<AppearanceSection />
@ -73,14 +74,14 @@ const isComponentType = computed(() => {
class="rounded bg-component/10 px-2 py-1 text-left text-[11px] text-component hover:bg-component/20"
@click="goToMainComponent.run()"
>
Go to Main Component
{{ panels.goToMainComponent }}
</button>
<button
data-test-id="design-detach-instance"
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="detachInstance.run()"
>
Detach Instance
{{ panels.detachInstance }}
</button>
</div>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge'
import { FillPickerRoot } from '@open-pencil/vue'
import { FillPickerRoot, useI18n } from '@open-pencil/vue'
import GradientEditor from './GradientEditor.vue'
import HsvColorArea from './HsvColorArea.vue'
@ -24,6 +24,7 @@ function tabClass(active: boolean) {
const { fill } = defineProps<{ fill: Fill }>()
const emit = defineEmits<{ update: [fill: Fill] }>()
const cls = usePopoverUI({ content: 'w-60 p-2' })
const { panels } = useI18n()
</script>
<template>
@ -35,7 +36,7 @@ const cls = usePopoverUI({ content: 'w-60 p-2' })
>
<template #default="{ fill: currentFill, category, toSolid, toGradient, toImage, update }">
<div class="mb-2 flex items-center gap-0.5">
<Tip label="Solid">
<Tip :label="panels.solid">
<button
:class="tabClass(category === 'SOLID')"
data-test-id="fill-picker-tab-solid"
@ -44,7 +45,7 @@ const cls = usePopoverUI({ content: 'w-60 p-2' })
<icon-lucide-square class="size-3.5" />
</button>
</Tip>
<Tip label="Gradient">
<Tip :label="panels.linearGradient">
<button
:class="tabClass(category === 'GRADIENT')"
data-test-id="fill-picker-tab-gradient"
@ -53,7 +54,7 @@ const cls = usePopoverUI({ content: 'w-60 p-2' })
<icon-lucide-blend class="size-3.5" />
</button>
</Tip>
<Tip label="Image">
<Tip :label="panels.image">
<button
:class="tabClass(category === 'IMAGE')"
data-test-id="fill-picker-tab-image"

View file

@ -4,12 +4,13 @@ import Tip from './ui/Tip.vue'
import HsvColorArea from './HsvColorArea.vue'
import ScrubInput from './ScrubInput.vue'
import { colorToCSS } from '@open-pencil/core'
import { GradientEditorRoot, GradientEditorBar, GradientEditorStop } from '@open-pencil/vue'
import { GradientEditorRoot, GradientEditorBar, GradientEditorStop, useI18n } from '@open-pencil/vue'
import type { Fill } from '@open-pencil/core'
const { fill } = defineProps<{ fill: Fill }>()
const emit = defineEmits<{ update: [fill: Fill] }>()
const { panels } = useI18n()
</script>
<template>
@ -52,8 +53,8 @@ const emit = defineEmits<{ update: [fill: Fill] }>()
<div class="mb-2">
<div class="mb-1 flex items-center justify-between">
<span class="text-[11px] text-muted">Stops</span>
<Tip label="Add stop">
<span class="text-[11px] text-muted">{{ panels.stops }}</span>
<Tip :label="panels.addStop">
<button
class="flex size-4 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted hover:text-surface"
data-test-id="fill-picker-add-stop"

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

@ -1,9 +1,13 @@
<script setup lang="ts">
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import AppMenu from './AppMenu.vue'
import LayerTree from './LayerTree.vue'
import PagesPanel from './PagesPanel.vue'
const { panels } = useI18n()
</script>
<template>
@ -32,7 +36,7 @@ import PagesPanel from './PagesPanel.vue'
data-test-id="layers-header"
class="shrink-0 px-3 py-2 text-[11px] tracking-wider text-muted uppercase"
>
Layers
{{ panels.layers }}
</header>
<LayerTree data-test-id="layers-tree" />
</SplitterPanel>

View file

@ -27,7 +27,7 @@ import { toast } from '@/utils/toast'
import { useEditorStore } from '@/stores/editor'
import Tip from '@/components/ui/Tip.vue'
import { colorToCSS } from '@open-pencil/core'
import { useEditorCommands } from '@open-pencil/vue'
import { useEditorCommands, useI18n } from '@open-pencil/vue'
import { toolIcons } from '@/utils/tools'
import { initials } from '@/utils/text'
@ -37,6 +37,7 @@ const router = useRouter()
const collab = useCollabInjected()
const store = useEditorStore()
const { copy } = useClipboard()
const { dialogs } = useI18n()
const collabState = computed(() => collab?.state.value ?? DEFAULT_COLLAB_STATE)
const collabPeers = computed(() => collab?.remotePeers.value ?? [])
@ -217,7 +218,7 @@ const onlineCount = computed(() => collabPeers.value.length + 1)
@click="onShare"
>
<icon-lucide-share-2 class="size-3.5 text-surface" />
<span class="text-xs text-surface">Share</span>
<span class="text-xs text-surface">{{ dialogs.share }}</span>
</button>
<DropdownMenuRoot>

View file

@ -1,13 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue'
import { PageListRoot, useInlineRename } from '@open-pencil/vue'
import { PageListRoot, useI18n, useInlineRename } from '@open-pencil/vue'
import Tip from '@/components/ui/Tip.vue'
const pageInputRefs = new Map<string, HTMLInputElement>()
const activeRenameId = ref<string | null>(null)
const rename = useInlineRename((id, name) => pageActions.value?.renamePage(id, name))
const { panels } = useI18n()
const pageActions = ref<{
renamePage: (pageId: string, name: string) => void
@ -46,9 +47,9 @@ function handlePageDblClick(
<div data-test-id="pages-panel" class="flex min-h-0 flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between px-3 py-1.5">
<span data-test-id="pages-header" class="text-[11px] tracking-wider text-muted uppercase"
>Pages</span
>{{ panels.pages }}</span
>
<Tip label="Add page">
<Tip :label="panels.addPage">
<button
data-test-id="pages-add"
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import { useAIChat } from '@/composables/use-chat'
import { useEditorStore } from '@/stores/editor'
@ -10,6 +11,7 @@ import DesignPanel from './DesignPanel.vue'
const store = useEditorStore()
const { activeTab } = useAIChat()
const { panels } = useI18n()
</script>
<template>
@ -25,7 +27,7 @@ const { activeTab } = useAIChat()
data-test-id="properties-tab-design"
class="rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
Design
{{ panels.design }}
</TabsTrigger>
<TabsTrigger
value="code"
@ -33,7 +35,7 @@ const { activeTab } = useAIChat()
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
<icon-lucide-code class="size-3" />
Code
{{ panels.code }}
</TabsTrigger>
<TabsTrigger
value="ai"
@ -41,7 +43,7 @@ const { activeTab } = useAIChat()
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
<icon-lucide-sparkles class="size-3" />
AI
{{ panels.ai }}
</TabsTrigger>
<span
v-if="activeTab === 'design'"

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,

View file

@ -14,7 +14,7 @@ import {
} from 'reka-ui'
import { FlexRender } from '@tanstack/vue-table'
import { useVariablesEditor } from '@open-pencil/vue'
import { useI18n, useVariablesEditor } from '@open-pencil/vue'
import IconHash from '~icons/lucide/hash'
import IconPalette from '~icons/lucide/palette'
@ -35,6 +35,8 @@ const variableTypeIcons: Record<string, Component> = {
BOOLEAN: IconToggleLeft
}
const { dialogs } = useI18n()
const ctx = useVariablesEditor({
colorInput: ColorInput,
icons: variableTypeIcons,
@ -50,7 +52,7 @@ const ctx = useVariablesEditor({
<DialogContent data-test-id="variables-dialog" :class="cls.content">
<div v-if="!ctx.hasCollections" class="flex flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between border-b border-border px-4 py-3">
<DialogTitle class="text-sm font-semibold text-surface">Local variables</DialogTitle>
<DialogTitle class="text-sm font-semibold text-surface">{{ dialogs.localVariables }}</DialogTitle>
<DialogClose
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
>
@ -59,13 +61,13 @@ const ctx = useVariablesEditor({
</div>
<div class="flex flex-1 items-center justify-center">
<div class="text-center">
<p class="text-sm text-muted">No variable collections</p>
<p class="text-sm text-muted">{{ dialogs.noVariableCollections }}</p>
<button
data-test-id="variables-create-collection"
class="mt-2 cursor-pointer rounded bg-hover px-3 py-1.5 text-xs text-surface hover:bg-border"
@click="ctx.addCollection"
>
Create collection
{{ dialogs.createCollection }}
</button>
</div>
</div>
@ -107,10 +109,10 @@ const ctx = useVariablesEditor({
v-model="ctx.searchTerm.value"
data-test-id="variables-search-input"
class="w-24 border-none bg-transparent text-xs text-surface outline-none placeholder:text-muted"
placeholder="Search"
:placeholder="dialogs.search"
/>
</div>
<Tip label="Add collection">
<Tip :label="dialogs.createCollection">
<button
data-test-id="variables-add-collection"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"

View file

@ -8,10 +8,12 @@ import Tip from '@/components/ui/Tip.vue'
import { uiButton } from '@/components/ui/button'
import { uiInput } from '@/components/ui/input'
import { useAIChat } from '@/composables/use-chat'
import { useI18n } from '@open-pencil/vue'
import { ACP_AGENTS } from '@open-pencil/core'
const { providerID, providerDef, modelID, customModelID } = useAIChat()
const { dialogs } = useI18n()
const { status } = defineProps<{
status: 'ready' | 'submitted' | 'streaming' | 'error'
@ -83,14 +85,14 @@ function handleSubmit(e: Event) {
v-model="input"
type="text"
data-test-id="chat-input"
placeholder="Describe a change…"
:placeholder="dialogs.describeChange"
:class="uiInput({ ui: { base: 'min-w-0 flex-1 placeholder:text-muted' } })"
:disabled="isStreaming"
@paste.stop
@copy.stop
@cut.stop
/>
<Tip v-if="isStreaming" label="Stop generating">
<Tip v-if="isStreaming" :label="dialogs.stopGenerating">
<button
type="button"
data-test-id="chat-stop-button"
@ -107,7 +109,7 @@ function handleSubmit(e: Event) {
<icon-lucide-square class="size-3" />
</button>
</Tip>
<Tip v-else label="Send message">
<Tip v-else :label="dialogs.sendMessage">
<button
type="submit"
data-test-id="chat-send-button"

View file

@ -5,8 +5,10 @@ import ProviderSelectField from '@/components/chat/ProviderSelectField.vue'
import { uiInput } from '@/components/ui/input'
import { useAIChat } from '@/composables/use-chat'
import { ACP_AGENTS } from '@open-pencil/core'
import { useI18n } from '@open-pencil/vue'
const { providerID, providerDef, setAPIKey, customBaseURL, customModelID } = useAIChat()
const { dialogs } = useI18n()
const isACP = computed(() => providerID.value.startsWith('acp:'))
const acpAgent = computed(() => {
@ -36,7 +38,7 @@ function save() {
<template>
<div data-test-id="provider-setup" class="flex flex-1 flex-col items-center justify-center px-6">
<icon-lucide-sparkles class="mb-3 size-7 text-muted" />
<p class="mb-5 text-center text-xs text-muted">Connect an AI provider to start chatting.</p>
<p class="mb-5 text-center text-xs text-muted">{{ dialogs.connectAIProvider }}</p>
<form v-if="!isACP" class="flex w-full flex-col gap-2" @submit.prevent="save">
<ProviderSelectField test-id="provider-selector" />
@ -47,7 +49,7 @@ function save() {
v-model="baseURLInput"
type="text"
data-test-id="provider-base-url"
placeholder="Base URL (e.g. http://localhost:11434/v1)"
:placeholder="dialogs.baseURLPlaceholder"
:class="uiInput()"
/>
@ -57,7 +59,7 @@ function save() {
v-model="customModelInput"
type="text"
data-test-id="provider-custom-model"
placeholder="Model ID (e.g. llama-3.3-70b)"
:placeholder="dialogs.modelIDPlaceholder"
:class="uiInput()"
/>
@ -75,7 +77,7 @@ function save() {
class="mt-1 w-full rounded bg-accent py-1.5 text-xs font-medium text-white hover:bg-accent/90"
:disabled="!keyInput.trim()"
>
Connect
{{ dialogs.connect }}
</button>
</form>
@ -99,14 +101,14 @@ function save() {
data-test-id="api-key-get-link"
class="mt-2.5 text-[10px] text-muted underline hover:text-surface"
>
Get an {{ providerDef.name }} API key →
{{ dialogs.getAPIKey({ provider: providerDef.name }) }}
</a>
<p
v-if="providerID === 'openrouter'"
class="mt-3 text-center text-[10px] leading-relaxed text-muted/50"
>
One key for 100+ models from all providers.
{{ dialogs.oneKeyManyModels }}
</p>
</div>
</template>

View file

@ -1,9 +1,11 @@
<script setup lang="ts">
import { AppearanceControlsRoot } from '@open-pencil/vue'
import { AppearanceControlsRoot, useI18n } from '@open-pencil/vue'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { sectionWrapper } from '@/components/ui/section'
const { panels } = useI18n()
</script>
<template>
@ -27,8 +29,8 @@ import { sectionWrapper } from '@/components/ui/section'
>
<div v-if="active" data-test-id="appearance-section" :class="sectionWrapper()">
<div class="mb-1.5 flex items-center justify-between">
<label class="text-[11px] text-muted">Appearance</label>
<Tip label="Toggle visibility">
<label class="text-[11px] text-muted">{{ panels.appearance }}</label>
<Tip :label="panels.toggleVisibility">
<button
data-test-id="appearance-visibility"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-muted hover:bg-hover hover:text-surface"
@ -67,7 +69,7 @@ import { sectionWrapper } from '@/components/ui/section'
<icon-lucide-radius class="size-3" />
</template>
</ScrubInput>
<Tip label="Independent corner radii">
<Tip :label="panels.independentCornerRadii">
<button
data-test-id="independent-corners-toggle"
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"

View file

@ -4,24 +4,25 @@ import ColorInput from '@/components/ColorInput.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { iconButton } from '@/components/ui/icon-button'
import { sectionLabel, sectionWrapper } from '@/components/ui/section'
import { PropertyListRoot, useEffectsControls } from '@open-pencil/vue'
import { PropertyListRoot, useEffectsControls, useI18n } from '@open-pencil/vue'
import { colorToCSS } from '@open-pencil/core'
import type { Effect } from '@open-pencil/core'
const effectsCtx = useEffectsControls()
const { panels } = useI18n()
</script>
<template>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, patch, add, remove, toggleVisibility }"
prop-key="effects"
label="Effects"
:label="panels.effects"
>
<div data-test-id="effects-section" :class="sectionWrapper()">
<div class="flex items-center justify-between">
<label :class="sectionLabel()">Effects</label>
<label :class="sectionLabel()">{{ panels.effects }}</label>
<button
data-test-id="effects-section-add"
:class="iconButton()"
@ -31,7 +32,7 @@ const effectsCtx = useEffectsControls()
</button>
</div>
<p v-if="isMixed" class="text-[11px] text-muted">Click + to replace mixed effects</p>
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedEffectsHelp }}</p>
<div
v-for="(effect, i) in items as Effect[]"

View file

@ -5,11 +5,12 @@ import AppSelect from '@/components/ui/AppSelect.vue'
import { iconButton } from '@/components/ui/icon-button'
import { sectionLabel, sectionWrapper } from '@/components/ui/section'
import { useEditorStore } from '@/stores/editor'
import { useExport } from '@open-pencil/vue'
import { useExport, useI18n } from '@open-pencil/vue'
import type { ExportFormat } from '@open-pencil/core'
const editorStore = useEditorStore()
const { panels } = useI18n()
const { settings, nodeName, addSetting, removeSetting, updateScale, updateFormat } = useExport()
const SCALE_OPTIONS = [0.5, 0.75, 1, 1.5, 2, 3, 4].map((s) => ({ value: s, label: `${s}x` }))
@ -72,7 +73,7 @@ onScopeDispose(() => {
<template>
<div data-test-id="export-section" :class="sectionWrapper()">
<div class="flex items-center justify-between">
<label :class="sectionLabel()">Export</label>
<label :class="sectionLabel()">{{ panels.export }}</label>
<button data-test-id="export-section-add" :class="iconButton()" @click="addSetting">+</button>
</div>

View file

@ -12,7 +12,7 @@ import {
} from 'reka-ui'
import { colorToCSS, colorToHexRaw } from '@open-pencil/core'
import { PropertyListRoot, useFillControls } from '@open-pencil/vue'
import { PropertyListRoot, useFillControls, useI18n } from '@open-pencil/vue'
import FillPicker from '@/components/FillPicker.vue'
import ScrubInput from '@/components/ScrubInput.vue'
@ -23,17 +23,18 @@ import { sectionLabel, sectionWrapper } from '@/components/ui/section'
import type { Fill, Variable } from '@open-pencil/core'
const fillCtx = useFillControls()
const { panels, dialogs } = useI18n()
</script>
<template>
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, add, remove, update, patch, toggleVisibility }"
prop-key="fills"
label="Fill"
:label="panels.fill"
>
<div data-test-id="fill-section" :class="sectionWrapper()">
<div class="flex items-center justify-between">
<label :class="sectionLabel()">Fill</label>
<label :class="sectionLabel()">{{ panels.fill }}</label>
<button
data-test-id="fill-section-add"
:class="iconButton()"
@ -42,7 +43,7 @@ const fillCtx = useFillControls()
+
</button>
</div>
<p v-if="isMixed" class="text-[11px] text-muted">Click + to replace mixed fills</p>
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
<div
v-for="(fill, i) in items as Fill[]"
:key="i"
@ -58,7 +59,7 @@ const fillCtx = useFillControls()
>
{{ fillCtx.getBoundVariable(activeNode.id, i)!.name }}
</span>
<Tip label="Detach variable">
<Tip :label="panels.detachVariable">
<button
data-test-id="fill-unbind-variable"
class="cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
@ -95,7 +96,7 @@ const fillCtx = useFillControls()
!fillCtx.getBoundVariable(activeNode.id, i)
"
>
<Tip label="Apply variable">
<Tip :label="panels.applyVariable">
<PopoverTrigger
class="cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
>
@ -115,13 +116,13 @@ const fillCtx = useFillControls()
>
<ComboboxInput
:model-value="fillCtx.searchTerm.value"
placeholder="Search variables…"
:placeholder="dialogs.search"
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
@update:model-value="fillCtx.searchTerm.value = String($event)"
/>
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted"
>No variables found</ComboboxEmpty
>{{ panels.noVariablesFound }}</ComboboxEmpty
>
<ComboboxItem
v-for="v in fillCtx.filteredVariables.value"

View file

@ -1,17 +1,19 @@
<script setup lang="ts">
import { LayoutControlsRoot } from '@open-pencil/vue'
import { LayoutControlsRoot, useI18n } from '@open-pencil/vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { sectionWrapper } from '@/components/ui/section'
const { panels } = useI18n()
</script>
<template>
<LayoutControlsRoot v-slot="ctx">
<template v-if="ctx.node">
<div data-test-id="layout-section" :class="sectionWrapper()">
<label class="mb-1.5 block text-[11px] text-muted">Layout</label>
<label class="mb-1.5 block text-[11px] text-muted">{{ panels.layout }}</label>
<div class="flex gap-1.5">
<div class="flex min-w-0 flex-1 items-center gap-1">
<ScrubInput
@ -49,8 +51,8 @@ import { sectionWrapper } from '@/components/ui/section'
<template v-if="ctx.node.type === 'FRAME'">
<div :class="sectionWrapper()">
<div class="flex items-center justify-between">
<label class="mb-1.5 block text-[11px] text-muted">Auto layout</label>
<Tip v-if="ctx.node.layoutMode === 'NONE'" label="Add auto layout (Shift+A)">
<label class="mb-1.5 block text-[11px] text-muted">{{ panels.autoLayout }}</label>
<Tip v-if="ctx.node.layoutMode === 'NONE'" :label="panels.addAutoLayout">
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
data-test-id="layout-add-auto"
@ -59,7 +61,7 @@ import { sectionWrapper } from '@/components/ui/section'
+
</button>
</Tip>
<Tip v-else label="Remove auto layout">
<Tip v-else :label="panels.removeAutoLayout">
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
data-test-id="layout-remove-auto"
@ -238,7 +240,7 @@ import { sectionWrapper } from '@/components/ui/section'
</template>
<div v-if="ctx.isFlex" class="mt-2">
<label class="mb-1 block text-[11px] text-muted">Alignment</label>
<label class="mb-1 block text-[11px] text-muted">{{ panels.alignment }}</label>
<div data-test-id="layout-alignment-grid" class="grid w-fit grid-cols-3 gap-0.5">
<button
v-for="cell in ctx.alignGrid"

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import ColorInput from '@/components/ColorInput.vue'
import { sectionWrapper } from '@/components/ui/section'
@ -8,11 +9,12 @@ import { useEditorStore } from '@/stores/editor'
const editor = useEditorStore()
const pageColor = computed(() => editor.state.pageColor)
const { panels } = useI18n()
</script>
<template>
<div data-test-id="page-section" :class="sectionWrapper()">
<label class="mb-1.5 block text-[11px] text-muted">Page</label>
<label class="mb-1.5 block text-[11px] text-muted">{{ panels.page ?? 'Page' }}</label>
<ColorInput :color="pageColor" editable @update="editor.setPageColor($event)" />
</div>
</template>

View file

@ -3,7 +3,9 @@ import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { iconButton } from '@/components/ui/icon-button'
import { sectionWrapper } from '@/components/ui/section'
import { PositionControlsRoot } from '@open-pencil/vue'
import { PositionControlsRoot, useI18n } from '@open-pencil/vue'
const { panels } = useI18n()
</script>
<template>
@ -24,11 +26,11 @@ import { PositionControlsRoot } from '@open-pencil/vue'
}"
>
<div v-if="active" data-test-id="position-section" :class="sectionWrapper()">
<label class="mb-1.5 block text-[11px] text-muted">Position</label>
<label class="mb-1.5 block text-[11px] text-muted">{{ panels.position }}</label>
<div class="mb-1.5 flex gap-2">
<div class="flex gap-0.5">
<Tip label="Align left">
<Tip :label="panels.alignLeft">
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-left"
@ -37,7 +39,7 @@ import { PositionControlsRoot } from '@open-pencil/vue'
<icon-lucide-align-horizontal-justify-start class="size-3.5" />
</button>
</Tip>
<Tip label="Align center horizontally">
<Tip :label="panels.alignCenterHorizontally">
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-center-h"
@ -46,7 +48,7 @@ import { PositionControlsRoot } from '@open-pencil/vue'
<icon-lucide-align-horizontal-justify-center class="size-3.5" />
</button>
</Tip>
<Tip label="Align right">
<Tip :label="panels.alignRight">
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-right"
@ -57,7 +59,7 @@ import { PositionControlsRoot } from '@open-pencil/vue'
</Tip>
</div>
<div class="flex gap-0.5">
<Tip label="Align top">
<Tip :label="panels.alignTop">
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-top"
@ -66,7 +68,7 @@ import { PositionControlsRoot } from '@open-pencil/vue'
<icon-lucide-align-vertical-justify-start class="size-3.5" />
</button>
</Tip>
<Tip label="Align center vertically">
<Tip :label="panels.alignCenterVertically">
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-center-v"
@ -75,7 +77,7 @@ import { PositionControlsRoot } from '@open-pencil/vue'
<icon-lucide-align-vertical-justify-center class="size-3.5" />
</button>
</Tip>
<Tip label="Align bottom">
<Tip :label="panels.alignBottom">
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-bottom"
@ -133,7 +135,7 @@ import { PositionControlsRoot } from '@open-pencil/vue'
<icon-lucide-rotate-ccw class="size-3" />
</template>
</ScrubInput>
<Tip label="Flip horizontal">
<Tip :label="panels.flipHorizontal">
<button
:class="iconButton({ size: 'md', ui: { base: 'shrink-0' } })"
data-test-id="position-flip-horizontal"
@ -142,7 +144,7 @@ import { PositionControlsRoot } from '@open-pencil/vue'
<icon-lucide-flip-horizontal class="size-3.5" />
</button>
</Tip>
<Tip label="Flip vertical">
<Tip :label="panels.flipVertical">
<button
:class="iconButton({ size: 'md', ui: { base: 'shrink-0' } })"
data-test-id="position-flip-vertical"
@ -151,7 +153,7 @@ import { PositionControlsRoot } from '@open-pencil/vue'
<icon-lucide-flip-vertical class="size-3.5" />
</button>
</Tip>
<Tip label="Rotate 90°">
<Tip :label="panels.rotate90">
<button
:class="iconButton({ size: 'md', ui: { base: 'shrink-0' } })"
data-test-id="position-rotate-90"

View file

@ -7,7 +7,7 @@ import {
DropdownMenuTrigger
} from 'reka-ui'
import { PropertyListRoot, useStrokeControls } from '@open-pencil/vue'
import { PropertyListRoot, useStrokeControls, useI18n } from '@open-pencil/vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import ColorInput from '@/components/ColorInput.vue'
@ -20,6 +20,7 @@ import { sectionLabel, sectionWrapper } from '@/components/ui/section'
import type { SceneNode, Stroke } from '@open-pencil/core'
const strokeCtx = useStrokeControls()
const { panels } = useI18n()
const sideMenuCls = useMenuUI({
content: 'min-w-[140px] rounded-md p-0.5',
item: 'relative px-2'
@ -30,11 +31,11 @@ const sideMenuCls = useMenuUI({
<PropertyListRoot
v-slot="{ items, isMixed, activeNode, add, remove, patch, toggleVisibility }"
prop-key="strokes"
label="Stroke"
:label="panels.stroke"
>
<div data-test-id="stroke-section" :class="sectionWrapper()">
<div class="flex items-center justify-between">
<label :class="sectionLabel()">Stroke</label>
<label :class="sectionLabel()">{{ panels.stroke }}</label>
<button
data-test-id="stroke-section-add"
:class="iconButton()"
@ -44,7 +45,7 @@ const sideMenuCls = useMenuUI({
</button>
</div>
<p v-if="isMixed" class="text-[11px] text-muted">Click + to replace mixed strokes</p>
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
<div
v-for="(stroke, i) in items as Stroke[]"
@ -101,7 +102,7 @@ const sideMenuCls = useMenuUI({
</template>
</ScrubInput>
<DropdownMenuRoot v-model:open="strokeCtx.sideMenuOpen.value">
<Tip label="Stroke sides">
<Tip :label="panels.strokeSides">
<DropdownMenuTrigger as-child>
<button
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ToggleGroupItem, ToggleGroupRoot } from 'reka-ui'
import { TypographyControlsRoot } from '@open-pencil/vue'
import { TypographyControlsRoot, useI18n } from '@open-pencil/vue'
import FontPicker from '@/components/FontPicker.vue'
import ScrubInput from '@/components/ScrubInput.vue'
@ -9,12 +9,14 @@ import AppSelect from '@/components/ui/AppSelect.vue'
import Tip from '@/components/ui/Tip.vue'
import { sectionWrapper } from '@/components/ui/section'
import { loadFont } from '@/engine/fonts'
const { panels } = useI18n()
</script>
<template>
<TypographyControlsRoot v-slot="ctx" :load-font="loadFont">
<div v-if="ctx.node.value" data-test-id="typography-section" :class="sectionWrapper()">
<label class="mb-1.5 block text-[11px] text-muted">Typography</label>
<label class="mb-1.5 block text-[11px] text-muted">{{ panels.typography }}</label>
<div class="mb-1.5 flex items-center gap-1.5">
<FontPicker

View file

@ -3,7 +3,7 @@ import { computed } from 'vue'
import Tip from '@/components/ui/Tip.vue'
import { sectionWrapper } from '@/components/ui/section'
import { useSceneComputed } from '@open-pencil/vue'
import { useI18n, useSceneComputed } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
@ -19,13 +19,14 @@ const variableCount = useSceneComputed(() => {
return editor.getVariableCount()
})
const hasVariables = computed(() => variableCount.value > 0)
const { panels } = useI18n()
</script>
<template>
<div data-test-id="variables-section" :class="sectionWrapper()">
<div class="flex items-center justify-between">
<label class="text-[11px] font-medium text-surface">Variables</label>
<Tip label="Open variables">
<label class="text-[11px] font-medium text-surface">{{ panels.variables }}</label>
<Tip :label="panels.openVariables">
<button
data-test-id="variables-section-open"
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
@ -36,9 +37,8 @@ const hasVariables = computed(() => variableCount.value > 0)
</Tip>
</div>
<div v-if="hasVariables" class="mt-1 text-[11px] text-muted">
{{ variableCount }} variable{{ variableCount !== 1 ? 's' : '' }} in
{{ collectionCount }} collection{{ collectionCount !== 1 ? 's' : '' }}
{{ variableCount }} / {{ collectionCount }}
</div>
<div v-else class="mt-1 text-[11px] text-muted">No local variables</div>
<div v-else class="mt-1 text-[11px] text-muted">{{ panels.noLocalVariables }}</div>
</div>
</template>

View file

@ -2,139 +2,154 @@ 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, locale, availableLocales, localeLabels, setLocale } = useI18n()
const textMenu = [
{ label: 'Bold', shortcut: `${mod}B` },
{ label: 'Italic', shortcut: `${mod}I` },
{ label: 'Underline', shortcut: `${mod}U` }
]
const fileMenu = [
{
label: 'New',
shortcut: `${mod}N`,
action: () => import('@/stores/tabs').then((m) => m.createTab())
},
{ 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
},
{ separator: true as const },
{
label: 'Auto-save to local file',
get checked() {
return store.state.autosaveEnabled
},
onCheckedChange: (value: boolean) => {
store.state.autosaveEnabled = value
const languageMenu = computed<MenuEntry[]>(() =>
availableLocales.map((code) => ({
label: localeLabels[code],
checked: locale.value === code,
onCheckedChange: (checked: boolean) => {
if (checked) setLocale(code)
}
}))
)
const topMenus = computed<AppMenuGroup[]>(() => [
{
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: 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`)
]
},
{
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()
}
},
{ separator: true as const },
{
label: t.value.language,
sub: languageMenu.value
}
]
},
{
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 }
}

View file

@ -12,7 +12,6 @@ import {
import { openFileDialog } from './use-menu'
import type { Tool } from '@open-pencil/core/editor'
import type { ComputedRef } from 'vue'
type NodeEditKeyboardMethods = Partial<{
@ -102,20 +101,12 @@ export function useKeyboard() {
if (html) store.pasteFromHTML(html, cursorPos)
})
// Space hold-to-pan: temporarily switch to HAND while Space is held,
// without losing object/node selections
let toolBeforeSpace: Tool | null = null
// Spacebar hold → temporary Hand tool (Figma-style canvas pan)
let toolBeforeSpace: (typeof store.state.activeTool) | null = null
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (isEditing(e)) return
if (
e.code === 'Space' &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.repeat &&
toolBeforeSpace === null
) {
if (e.code === 'Space' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat && toolBeforeSpace === null) {
if (store.state.activeTool !== 'HAND') {
toolBeforeSpace = store.state.activeTool
store.setTool('HAND')
@ -131,6 +122,7 @@ export function useKeyboard() {
e.preventDefault()
}
})
const keys = useMagicKeys({
passive: false,
onEventFired(e) {
@ -141,7 +133,7 @@ export function useKeyboard() {
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
// Space is handled by hold-to-pan above
if (e.code === 'Space') return
const tool = TOOL_SHORTCUTS[e.code]
const tool = TOOL_SHORTCUTS[e.key.toLowerCase()]
if (tool) {
// Permanent tool switch cancels space-hold
toolBeforeSpace = null
@ -246,8 +238,8 @@ export function useKeyboard() {
)
}
whenever(plain('bracketright'), () => store.bringToFront())
whenever(plain('bracketleft'), () => store.sendToBack())
whenever(plain('bracketright'), () => runCommand('selection.bringToFront'))
whenever(plain('bracketleft'), () => runCommand('selection.sendToBack'))
whenever(plain('backspace'), () => {
if (
store.state.nodeEditState &&