refactor(app): share browser and tauri menu model

This commit is contained in:
Danila Poyarkov 2026-04-30 16:01:47 +03:00
parent 0d16354c89
commit 28d67818e2
8 changed files with 880 additions and 424 deletions

View file

@ -305,6 +305,9 @@ Self-review checklist:
## UI
- Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.)
- Browser and Tauri menus share `src/app/shell/menu/schema.ts` as the canonical menu model. Do not add menu items directly in `src/components/AppMenu.vue` or `desktop/src/menu.rs`.
- Regenerate the native menu with `bun run generate:tauri-menu` after editing the shared menu schema; `desktop/generated/menu.json` is consumed by the Tauri menu builder.
- Every shared menu item with an `id` must be handled by `src/app/shell/menu/use.ts`, an editor command, or explicitly marked browser/native-only in the schema.
- Tailwind 4 for styling — no inline CSS, no component-level `<style>` blocks
- Mac keyboards: use `e.code` not `e.key` for shortcuts with modifiers (Option transforms characters)
- Splitter resize handles need inner div with `pointer-events-none` for sizing (zero-width handle collapses without it)

288
desktop/generated/menu.json Normal file
View file

@ -0,0 +1,288 @@
[
{
"label": "File",
"items": [
{
"id": "new",
"label": "New",
"accelerator": "CmdOrCtrl+N"
},
{
"id": "open",
"label": "Open…",
"accelerator": "CmdOrCtrl+O"
},
{
"type": "separator"
},
{
"id": "save",
"label": "Save",
"accelerator": "CmdOrCtrl+S"
},
{
"id": "save-as",
"label": "Save As…",
"accelerator": "CmdOrCtrl+Shift+S"
},
{
"type": "separator"
},
{
"id": "export-selection",
"label": "Export Selection",
"accelerator": "CmdOrCtrl+Shift+E",
"sub": [
{
"id": "export-png",
"label": "PNG"
},
{
"id": "export-svg",
"label": "SVG"
},
{
"id": "export-fig",
"label": ".fig"
}
]
},
{
"type": "separator"
},
{
"id": "autosave",
"label": "Autosave",
"checkbox": true
},
{
"id": "close",
"label": "Close Tab",
"accelerator": "CmdOrCtrl+W"
}
]
},
{
"label": "Edit",
"items": [
{
"id": "edit.undo",
"label": "Undo",
"accelerator": "CmdOrCtrl+Z"
},
{
"id": "edit.redo",
"label": "Redo",
"accelerator": "CmdOrCtrl+Shift+Z"
},
{
"type": "separator"
},
{
"id": "copy",
"label": "Copy",
"accelerator": "CmdOrCtrl+C"
},
{
"id": "paste",
"label": "Paste",
"accelerator": "CmdOrCtrl+V"
},
{
"id": "selection.duplicate",
"label": "Duplicate",
"accelerator": "CmdOrCtrl+D"
},
{
"id": "selection.delete",
"label": "Delete",
"accelerator": "Backspace"
},
{
"type": "separator"
},
{
"id": "selection.selectAll",
"label": "Select All",
"accelerator": "CmdOrCtrl+A"
}
]
},
{
"label": "View",
"items": [
{
"id": "view.zoom100",
"label": "Zoom to 100%",
"accelerator": "CmdOrCtrl+0"
},
{
"id": "view.zoomFit",
"label": "Zoom to Fit",
"accelerator": "CmdOrCtrl+1"
},
{
"id": "view.zoomSelection",
"label": "Zoom to Selection",
"accelerator": "CmdOrCtrl+2"
},
{
"id": "zoom-in",
"label": "Zoom In",
"accelerator": "CmdOrCtrl+="
},
{
"id": "zoom-out",
"label": "Zoom Out",
"accelerator": "CmdOrCtrl+-"
},
{
"type": "separator"
},
{
"id": "theme",
"label": "Theme",
"sub": [
{
"id": "theme-light",
"label": "Light",
"checkbox": true
},
{
"id": "theme-dark",
"label": "Dark",
"checkbox": true
},
{
"id": "theme-auto",
"label": "Auto",
"checkbox": true
}
]
},
{
"type": "separator"
},
{
"id": "toggle-ui",
"label": "Toggle UI",
"accelerator": "CmdOrCtrl+\\"
},
{
"id": "dev-tools",
"label": "Developer Tools",
"accelerator": "CmdOrCtrl+Alt+I"
}
]
},
{
"label": "Object",
"items": [
{
"id": "selection.group",
"label": "Group Selection",
"accelerator": "CmdOrCtrl+G"
},
{
"id": "selection.ungroup",
"label": "Ungroup Selection",
"accelerator": "CmdOrCtrl+Shift+G"
},
{
"type": "separator"
},
{
"id": "selection.createComponent",
"label": "Create Component",
"accelerator": "CmdOrCtrl+Alt+K"
},
{
"id": "selection.createComponentSet",
"label": "Create Component Set"
},
{
"id": "selection.detachInstance",
"label": "Detach Instance"
},
{
"type": "separator"
},
{
"id": "selection.bringToFront",
"label": "Bring to Front",
"accelerator": "]"
},
{
"id": "selection.sendToBack",
"label": "Send to Back",
"accelerator": "["
}
]
},
{
"label": "Text",
"items": [
{
"id": "text.bold",
"label": "Bold",
"accelerator": "CmdOrCtrl+B"
},
{
"id": "text.italic",
"label": "Italic",
"accelerator": "CmdOrCtrl+I"
},
{
"id": "text.underline",
"label": "Underline",
"accelerator": "CmdOrCtrl+U"
}
]
},
{
"label": "Arrange",
"items": [
{
"id": "selection.wrapInAutoLayout",
"label": "Wrap in Auto Layout",
"accelerator": "Shift+A"
},
{
"type": "separator"
},
{
"id": "align-left",
"label": "Align Left",
"accelerator": "Alt+A"
},
{
"id": "align-center",
"label": "Align Center",
"accelerator": "Alt+H"
},
{
"id": "align-right",
"label": "Align Right",
"accelerator": "Alt+D"
},
{
"type": "separator"
},
{
"id": "align-top",
"label": "Align Top",
"accelerator": "Alt+W"
},
{
"id": "align-middle",
"label": "Align Middle",
"accelerator": "Alt+V"
},
{
"id": "align-bottom",
"label": "Align Bottom",
"accelerator": "Alt+S"
}
]
}
]

View file

@ -1,4 +1,65 @@
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder};
use serde::Deserialize;
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, Submenu, SubmenuBuilder};
#[derive(Deserialize)]
struct MenuGroup {
label: String,
items: Vec<MenuEntry>,
}
#[derive(Deserialize)]
struct MenuEntry {
#[serde(default)]
r#type: Option<String>,
id: Option<String>,
label: Option<String>,
accelerator: Option<String>,
#[serde(default)]
sub: Vec<MenuEntry>,
}
fn build_submenu<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
label: &str,
items: &[MenuEntry],
) -> tauri::Result<Submenu<R>> {
let mut builder = SubmenuBuilder::new(app, label);
for entry in items {
if entry.r#type.as_deref() == Some("separator") {
builder = builder.separator();
continue;
}
let label = entry.label.as_deref().unwrap_or_default();
if !entry.sub.is_empty() {
let submenu = build_submenu(app, label, &entry.sub)?;
builder = builder.item(&submenu);
continue;
}
let mut item = MenuItemBuilder::new(label);
if let Some(id) = &entry.id {
item = item.id(id);
}
if let Some(accelerator) = &entry.accelerator {
item = item.accelerator(accelerator);
}
builder = builder.item(&item.build(app)?);
}
builder.build()
}
fn build_schema_menus<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
) -> tauri::Result<Vec<Submenu<R>>> {
let groups: Vec<MenuGroup> = serde_json::from_str(include_str!("../generated/menu.json"))?;
groups
.iter()
.map(|group| build_submenu(app, &group.label, &group.items))
.collect()
}
pub fn install_app_menu<R: tauri::Runtime>(app: &mut tauri::App<R>) -> tauri::Result<()> {
#[cfg(target_os = "macos")]
@ -18,237 +79,19 @@ pub fn install_app_menu<R: tauri::Runtime>(app: &mut tauri::App<R>) -> tauri::Re
.item(&PredefinedMenuItem::quit(app, None)?)
.build()?;
#[allow(unused_mut)]
let mut file_menu_builder = SubmenuBuilder::new(app, "File")
.item(
&MenuItemBuilder::new("New")
.id("new")
.accelerator("CmdOrCtrl+N")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Open…")
.id("open")
.accelerator("CmdOrCtrl+O")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Save")
.id("save")
.accelerator("CmdOrCtrl+S")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Save As…")
.id("save-as")
.accelerator("CmdOrCtrl+Shift+S")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Export…")
.id("export")
.accelerator("CmdOrCtrl+Shift+E")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Close Tab")
.id("close")
.accelerator("CmdOrCtrl+W")
.build(app)?,
);
#[cfg(not(target_os = "macos"))]
{
file_menu_builder = file_menu_builder
.separator()
.item(&PredefinedMenuItem::quit(app, None)?);
}
let file_menu = file_menu_builder.build()?;
let edit_menu = SubmenuBuilder::new(app, "Edit")
.item(&PredefinedMenuItem::undo(app, None)?)
.item(&PredefinedMenuItem::redo(app, None)?)
.separator()
.item(&PredefinedMenuItem::cut(app, None)?)
.item(&PredefinedMenuItem::copy(app, None)?)
.item(&PredefinedMenuItem::paste(app, None)?)
.item(
&MenuItemBuilder::new("Paste in Place")
.id("paste-in-place")
.accelerator("CmdOrCtrl+Shift+V")
.build(app)?,
)
.item(&PredefinedMenuItem::select_all(app, None)?)
.separator()
.item(
&MenuItemBuilder::new("Duplicate")
.id("duplicate")
.accelerator("CmdOrCtrl+D")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Delete")
.id("delete")
.accelerator("Backspace")
.build(app)?,
)
.build()?;
let theme_menu = SubmenuBuilder::new(app, "Theme")
.item(&MenuItemBuilder::new("Light").id("theme-light").build(app)?)
.item(&MenuItemBuilder::new("Dark").id("theme-dark").build(app)?)
.item(&MenuItemBuilder::new("Auto").id("theme-auto").build(app)?)
.build()?;
let view_menu = SubmenuBuilder::new(app, "View")
.item(
&MenuItemBuilder::new("Zoom In")
.id("zoom-in")
.accelerator("CmdOrCtrl+=")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Zoom Out")
.id("zoom-out")
.accelerator("CmdOrCtrl+-")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Zoom to Fit")
.id("zoom-fit")
.accelerator("CmdOrCtrl+1")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Zoom to 100%")
.id("zoom-100")
.accelerator("CmdOrCtrl+0")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Toggle Rulers")
.id("toggle-rulers")
.accelerator("Shift+R")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Toggle Grid")
.id("toggle-grid")
.accelerator("CmdOrCtrl+'")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Toggle UI")
.id("toggle-ui")
.accelerator("CmdOrCtrl+\\")
.build(app)?,
)
.separator()
.item(&theme_menu)
.separator()
.item(&PredefinedMenuItem::fullscreen(app, None)?)
.separator()
.item(
&MenuItemBuilder::new("Developer Tools")
.id("dev-tools")
.accelerator("CmdOrCtrl+Alt+I")
.build(app)?,
)
.build()?;
let object_menu = SubmenuBuilder::new(app, "Object")
.item(
&MenuItemBuilder::new("Group Selection")
.id("group")
.accelerator("CmdOrCtrl+G")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Ungroup Selection")
.id("ungroup")
.accelerator("CmdOrCtrl+Shift+G")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Frame Selection")
.id("frame-selection")
.accelerator("CmdOrCtrl+Alt+G")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Bring to Front")
.id("bring-front")
.accelerator("CmdOrCtrl+]")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Send to Back")
.id("send-back")
.accelerator("CmdOrCtrl+[")
.build(app)?,
)
.separator()
.item(
&MenuItemBuilder::new("Flip Horizontal")
.id("flip-h")
.accelerator("Shift+H")
.build(app)?,
)
.item(
&MenuItemBuilder::new("Flip Vertical")
.id("flip-v")
.accelerator("Shift+V")
.build(app)?,
)
.build()?;
let window_menu = SubmenuBuilder::new(app, "Window")
.item(&PredefinedMenuItem::minimize(app, None)?)
.item(&PredefinedMenuItem::maximize(app, None)?)
.separator()
.item(&PredefinedMenuItem::close_window(app, None)?)
.build()?;
#[allow(unused_mut)]
let mut help_menu_builder = SubmenuBuilder::new(app, "Help").item(
&MenuItemBuilder::new("Keyboard Shortcuts")
.id("shortcuts")
.accelerator("CmdOrCtrl+/")
.build(app)?,
);
#[cfg(not(target_os = "macos"))]
{
help_menu_builder = help_menu_builder
.separator()
.item(&PredefinedMenuItem::about(
app,
Some("About OpenPencil"),
None,
)?);
}
let help_menu = help_menu_builder.build()?;
let handle = app.handle().clone();
let schema_menus = build_schema_menus(&handle)?;
let mut builder = MenuBuilder::new(app);
#[cfg(target_os = "macos")]
{
builder = builder.item(&app_menu);
}
let menu = builder
.items(&[
&file_menu,
&edit_menu,
&view_menu,
&object_menu,
&window_menu,
&help_menu,
])
.build()?;
app.set_menu(menu)?;
for menu in &schema_menus {
builder = builder.item(menu);
}
app.set_menu(builder.build()?)?;
Ok(())
}

View file

@ -28,7 +28,8 @@
"docs:dev": "bun --filter @open-pencil/docs dev",
"docs:build": "bun --filter @open-pencil/docs build",
"docs:preview": "bun --filter @open-pencil/docs preview",
"visual-compare": "bun scripts/visual-compare.ts"
"visual-compare": "bun scripts/visual-compare.ts",
"generate:tauri-menu": "bun scripts/generate-tauri-menu.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",

View file

@ -0,0 +1,35 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { APP_MENU_SCHEMA } from '../src/app/shell/menu/schema'
import type { AppMenuEntry, AppMenuGroupSchema } from '../src/app/shell/menu/schema'
function isNativeVisible(entry: { target?: string }): boolean {
return entry.target !== 'browser'
}
function cleanEntry(entry: AppMenuEntry): unknown | null {
if (!isNativeVisible(entry)) return null
if (entry.type === 'separator') return { type: 'separator' }
return {
id: entry.id,
label: entry.label,
accelerator: entry.accelerator,
checkbox: entry.checkbox,
sub: entry.sub?.map(cleanEntry).filter(Boolean)
}
}
function cleanGroup(group: AppMenuGroupSchema): unknown | null {
if (!isNativeVisible(group)) return null
return {
label: group.label,
items: group.items.map(cleanEntry).filter(Boolean)
}
}
const outputPath = 'desktop/generated/menu.json'
const menu = APP_MENU_SCHEMA.map(cleanGroup).filter(Boolean)
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, `${JSON.stringify(menu, null, 2)}\n`)

View file

@ -1,21 +1,39 @@
import { computed } from 'vue'
import { useEditorStore } from '@/app/editor/active-store'
import { APP_MENU_SCHEMA } from '@/app/shell/menu/schema'
import { openFileDialog } from '@/app/shell/menu/use'
import { useAppTheme } from '@/app/shell/theme'
import { useEditorCommands, useI18n } from '@open-pencil/vue'
import type { MenuEntry } from '@open-pencil/vue'
import type { AppMenuActionItem, AppMenuEntry, AppMenuGroupSchema } from '@/app/shell/menu/schema'
import type { EditorCommandId, MenuEntry } from '@open-pencil/vue'
export interface AppMenuGroup {
label: string
items: MenuEntry[]
}
function shortcutLabel(shortcut: string | undefined, mod: string): string | undefined {
return shortcut?.replaceAll('MOD', mod)
}
function isVisible(entry: { target?: string }): boolean {
return entry.target !== 'native'
}
function isSeparator(entry: AppMenuEntry): entry is Extract<AppMenuEntry, { type: 'separator' }> {
return entry.type === 'separator'
}
function isCommandId(id: string | undefined): id is EditorCommandId {
return Boolean(id?.includes('.'))
}
export function useAppMenu(mod: string) {
const store = useEditorStore()
const { menuItem: commandMenuItem } = useEditorCommands()
const { menu: t, panels: p, locale, availableLocales, localeLabels, setLocale } = useI18n()
const { locale, availableLocales, localeLabels, setLocale } = useI18n()
const { theme, setTheme } = useAppTheme()
const languageMenu = computed<MenuEntry[]>(() =>
@ -28,172 +46,143 @@ export function useAppMenu(mod: string) {
}))
)
const topMenus = computed<AppMenuGroup[]>(() => [
{
label: t.value.file,
items: [
{
label: t.value.new,
shortcut: `${mod}N`,
action: () => {
void import('@/app/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`,
sub: [
{
label: 'PNG',
action: () => {
void store.exportSelection(1, 'png')
}
},
{
label: 'SVG',
action: () => {
void store.exportSelection(1, 'svg')
}
},
{
label: '.fig',
action: () => {
void store.exportSelection(1, 'fig')
}
}
]
},
{ 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: 'Theme',
sub: [
{
label: 'Light',
checked: theme.value === 'light',
onCheckedChange: (checked: boolean) => {
if (checked) setTheme('light')
}
},
{
label: 'Dark',
checked: theme.value === 'dark',
onCheckedChange: (checked: boolean) => {
if (checked) setTheme('dark')
}
},
{
label: 'Auto',
checked: theme.value === 'auto',
onCheckedChange: (checked: boolean) => {
if (checked) setTheme('auto')
}
}
]
},
{
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.text,
items: [
{ label: t.value.bold, shortcut: `${mod}B` },
{ label: t.value.italic, shortcut: `${mod}I` },
{ label: t.value.underline, shortcut: `${mod}U` }
]
},
{
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' }
]
function exportSelection(format: 'png' | 'svg' | 'fig') {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, format)
}
function alignSelected(axis: 'horizontal' | 'vertical', align: 'min' | 'center' | 'max') {
store.alignNodes([...store.state.selectedIds], axis, align)
}
function updateSelectedText(updates: {
fontWeight?: number
italic?: boolean
textDecoration?: 'NONE' | 'UNDERLINE'
}) {
for (const node of store.selectedNodes.value) {
if (node.type === 'TEXT') store.updateNodeWithUndo(node.id, updates, 'Format text')
}
])
}
function selectedTextNode() {
return store.selectedNodes.value.find((item) => item.type === 'TEXT')
}
const actions: Partial<Record<string, () => void>> = {
new: () => {
void import('@/app/tabs').then((m) => m.createTab())
},
open: () => void openFileDialog(),
save: () => void store.saveFigFile(),
'save-as': () => void store.saveFigFileAs(),
'export-selection': () => exportSelection('png'),
'export-png': () => exportSelection('png'),
'export-svg': () => exportSelection('svg'),
'export-fig': () => exportSelection('fig'),
'zoom-in': () => store.applyZoom(-100, window.innerWidth / 2, window.innerHeight / 2),
'zoom-out': () => store.applyZoom(100, window.innerWidth / 2, window.innerHeight / 2),
'toggle-ui': () => {
store.state.showUI = !store.state.showUI
},
'theme-light': () => setTheme('light'),
'theme-dark': () => setTheme('dark'),
'theme-auto': () => setTheme('auto'),
'text.bold': () => {
const node = selectedTextNode()
updateSelectedText({ fontWeight: node && node.fontWeight >= 700 ? 400 : 700 })
},
'text.italic': () => {
const node = selectedTextNode()
updateSelectedText({ italic: node ? !node.italic : true })
},
'text.underline': () => {
const node = selectedTextNode()
updateSelectedText({
textDecoration: node?.textDecoration === 'UNDERLINE' ? 'NONE' : 'UNDERLINE'
})
},
'align-left': () => alignSelected('horizontal', 'min'),
'align-center': () => alignSelected('horizontal', 'center'),
'align-right': () => alignSelected('horizontal', 'max'),
'align-top': () => alignSelected('vertical', 'min'),
'align-middle': () => alignSelected('vertical', 'center'),
'align-bottom': () => alignSelected('vertical', 'max')
}
function itemAction(item: AppMenuActionItem): (() => void) | undefined {
return actions[item.id]
}
function checked(item: AppMenuActionItem): boolean | undefined {
switch (item.id) {
case 'autosave':
return store.state.autosaveEnabled
case 'profiler':
return store.renderer?.profiler.hudVisible ?? false
case 'theme-light':
return theme.value === 'light'
case 'theme-dark':
return theme.value === 'dark'
case 'theme-auto':
return theme.value === 'auto'
default:
return undefined
}
}
function onCheckedChange(item: AppMenuActionItem): ((checked: boolean) => void) | undefined {
switch (item.id) {
case 'autosave':
return (value: boolean) => {
store.state.autosaveEnabled = value
}
case 'profiler':
return () => store.toggleProfiler()
case 'theme-light':
case 'theme-dark':
case 'theme-auto':
return (value: boolean) => {
if (value) itemAction(item)?.()
}
default:
return undefined
}
}
function buildEntry(entry: AppMenuEntry): MenuEntry | null {
if (!isVisible(entry)) return null
if (isSeparator(entry)) return { separator: true }
if (entry.id === 'language') {
return { label: entry.label, sub: languageMenu.value }
}
if (entry.command ?? isCommandId(entry.id)) {
const command = entry.command ?? (entry.id as EditorCommandId)
return commandMenuItem(command, shortcutLabel(entry.shortcut, mod))
}
return {
label: entry.label,
shortcut: shortcutLabel(entry.shortcut, mod),
action: itemAction(entry),
checked: checked(entry),
onCheckedChange: onCheckedChange(entry),
sub: entry.sub?.map(buildEntry).filter((item): item is MenuEntry => item !== null)
}
}
function buildGroup(group: AppMenuGroupSchema): AppMenuGroup | null {
if (!isVisible(group)) return null
return {
label: group.label,
items: group.items.map(buildEntry).filter((item): item is MenuEntry => item !== null)
}
}
const topMenus = computed<AppMenuGroup[]>(() =>
APP_MENU_SCHEMA.map(buildGroup).filter((group): group is AppMenuGroup => group !== null)
)
return { topMenus }
}

View file

@ -0,0 +1,228 @@
import type { EditorCommandId } from '@open-pencil/vue'
export type AppMenuTarget = 'all' | 'browser' | 'native'
export interface AppMenuActionItem {
type?: 'item'
id: string
label: string
shortcut?: string
accelerator?: string
command?: EditorCommandId
checkbox?: boolean
target?: AppMenuTarget
sub?: AppMenuEntry[]
}
export interface AppMenuSeparatorItem {
type: 'separator'
target?: AppMenuTarget
}
export type AppMenuEntry = AppMenuActionItem | AppMenuSeparatorItem
export interface AppMenuGroupSchema {
label: string
target?: AppMenuTarget
items: AppMenuEntry[]
}
export const APP_MENU_SCHEMA = [
{
label: 'File',
items: [
{ id: 'new', label: 'New', shortcut: 'MOD+N', accelerator: 'CmdOrCtrl+N' },
{ id: 'open', label: 'Open…', shortcut: 'MOD+O', accelerator: 'CmdOrCtrl+O' },
{ type: 'separator' },
{ id: 'save', label: 'Save', shortcut: 'MOD+S', accelerator: 'CmdOrCtrl+S' },
{ id: 'save-as', label: 'Save As…', shortcut: 'MOD+⇧S', accelerator: 'CmdOrCtrl+Shift+S' },
{ type: 'separator' },
{
id: 'export-selection',
label: 'Export Selection',
shortcut: 'MOD+⇧E',
accelerator: 'CmdOrCtrl+Shift+E',
sub: [
{ id: 'export-png', label: 'PNG' },
{ id: 'export-svg', label: 'SVG' },
{ id: 'export-fig', label: '.fig' }
]
},
{ type: 'separator' },
{ id: 'autosave', label: 'Autosave', checkbox: true },
{ id: 'close', label: 'Close Tab', shortcut: 'MOD+W', accelerator: 'CmdOrCtrl+W' }
]
},
{
label: 'Edit',
items: [
{
id: 'edit.undo',
label: 'Undo',
shortcut: 'MOD+Z',
accelerator: 'CmdOrCtrl+Z',
command: 'edit.undo'
},
{
id: 'edit.redo',
label: 'Redo',
shortcut: 'MOD+⇧Z',
accelerator: 'CmdOrCtrl+Shift+Z',
command: 'edit.redo'
},
{ type: 'separator' },
{ id: 'copy', label: 'Copy', shortcut: 'MOD+C', accelerator: 'CmdOrCtrl+C' },
{ id: 'paste', label: 'Paste', shortcut: 'MOD+V', accelerator: 'CmdOrCtrl+V' },
{
id: 'selection.duplicate',
label: 'Duplicate',
shortcut: 'MOD+D',
accelerator: 'CmdOrCtrl+D',
command: 'selection.duplicate'
},
{
id: 'selection.delete',
label: 'Delete',
shortcut: '⌫',
accelerator: 'Backspace',
command: 'selection.delete'
},
{ type: 'separator' },
{
id: 'selection.selectAll',
label: 'Select All',
shortcut: 'MOD+A',
accelerator: 'CmdOrCtrl+A',
command: 'selection.selectAll'
}
]
},
{
label: 'View',
items: [
{
id: 'view.zoom100',
label: 'Zoom to 100%',
shortcut: 'MOD+0',
accelerator: 'CmdOrCtrl+0',
command: 'view.zoom100'
},
{
id: 'view.zoomFit',
label: 'Zoom to Fit',
shortcut: 'MOD+1',
accelerator: 'CmdOrCtrl+1',
command: 'view.zoomFit'
},
{
id: 'view.zoomSelection',
label: 'Zoom to Selection',
shortcut: 'MOD+2',
accelerator: 'CmdOrCtrl+2',
command: 'view.zoomSelection'
},
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'MOD+=', accelerator: 'CmdOrCtrl+=' },
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'MOD+-', accelerator: 'CmdOrCtrl+-' },
{ type: 'separator' },
{
id: 'theme',
label: 'Theme',
sub: [
{ id: 'theme-light', label: 'Light', checkbox: true },
{ id: 'theme-dark', label: 'Dark', checkbox: true },
{ id: 'theme-auto', label: 'Auto', checkbox: true }
]
},
{ id: 'language', label: 'Language', target: 'browser' },
{ type: 'separator' },
{ id: 'toggle-ui', label: 'Toggle UI', shortcut: 'MOD+\\', accelerator: 'CmdOrCtrl+\\' },
{ id: 'profiler', label: 'Profiler', checkbox: true, target: 'browser' },
{
id: 'dev-tools',
label: 'Developer Tools',
accelerator: 'CmdOrCtrl+Alt+I',
target: 'native'
}
]
},
{
label: 'Object',
items: [
{
id: 'selection.group',
label: 'Group Selection',
shortcut: 'MOD+G',
accelerator: 'CmdOrCtrl+G',
command: 'selection.group'
},
{
id: 'selection.ungroup',
label: 'Ungroup Selection',
shortcut: 'MOD+⇧G',
accelerator: 'CmdOrCtrl+Shift+G',
command: 'selection.ungroup'
},
{ type: 'separator' },
{
id: 'selection.createComponent',
label: 'Create Component',
shortcut: 'MOD+⌥K',
accelerator: 'CmdOrCtrl+Alt+K',
command: 'selection.createComponent'
},
{
id: 'selection.createComponentSet',
label: 'Create Component Set',
command: 'selection.createComponentSet'
},
{
id: 'selection.detachInstance',
label: 'Detach Instance',
command: 'selection.detachInstance'
},
{ type: 'separator' },
{
id: 'selection.bringToFront',
label: 'Bring to Front',
shortcut: ']',
accelerator: ']',
command: 'selection.bringToFront'
},
{
id: 'selection.sendToBack',
label: 'Send to Back',
shortcut: '[',
accelerator: '[',
command: 'selection.sendToBack'
}
]
},
{
label: 'Text',
items: [
{ id: 'text.bold', label: 'Bold', shortcut: 'MOD+B', accelerator: 'CmdOrCtrl+B' },
{ id: 'text.italic', label: 'Italic', shortcut: 'MOD+I', accelerator: 'CmdOrCtrl+I' },
{ id: 'text.underline', label: 'Underline', shortcut: 'MOD+U', accelerator: 'CmdOrCtrl+U' }
]
},
{
label: 'Arrange',
items: [
{
id: 'selection.wrapInAutoLayout',
label: 'Wrap in Auto Layout',
shortcut: '⇧A',
accelerator: 'Shift+A',
command: 'selection.wrapInAutoLayout'
},
{ type: 'separator' },
{ id: 'align-left', label: 'Align Left', shortcut: '⌥A', accelerator: 'Alt+A' },
{ id: 'align-center', label: 'Align Center', shortcut: '⌥H', accelerator: 'Alt+H' },
{ id: 'align-right', label: 'Align Right', shortcut: '⌥D', accelerator: 'Alt+D' },
{ type: 'separator' },
{ id: 'align-top', label: 'Align Top', shortcut: '⌥W', accelerator: 'Alt+W' },
{ id: 'align-middle', label: 'Align Middle', shortcut: '⌥V', accelerator: 'Alt+V' },
{ id: 'align-bottom', label: 'Align Bottom', shortcut: '⌥S', accelerator: 'Alt+S' }
]
}
] satisfies AppMenuGroupSchema[]

View file

@ -5,51 +5,120 @@ import { importFileDialog, openFileDialog } from '@/app/shell/menu/files'
import { useAppTheme } from '@/app/shell/theme'
import { createTab, closeTab, activeTab } from '@/app/tabs'
import { IS_TAURI } from '@/constants'
import { useEditorCommands } from '@open-pencil/vue'
import type { EditorCommandId } from '@open-pencil/vue'
const store = useEditorStore()
const MENU_ACTIONS: Partial<Record<string, () => void>> = {
new: () => createTab(),
open: () => void openFileDialog(),
import: () => void importFileDialog(),
close: () => {
if (activeTab.value) closeTab(activeTab.value.id)
},
save: () => void store.saveFigFile(),
'save-as': () => void store.saveFigFileAs(),
duplicate: () => store.duplicateSelected(),
delete: () => store.deleteSelected(),
group: () => store.groupSelected(),
ungroup: () => store.ungroupSelected(),
'create-component': () => store.createComponentFromSelection(),
'create-component-set': () => store.createComponentSetFromComponents(),
'detach-instance': () => store.detachInstance(),
'zoom-100': () => store.zoomTo100(),
'zoom-fit': () => store.zoomToFit(),
'zoom-selection': () => store.zoomToSelection(),
export: () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
}
}
const COMMAND_MENU_IDS = new Set<string>([
'edit.undo',
'edit.redo',
'selection.selectAll',
'selection.duplicate',
'selection.delete',
'selection.group',
'selection.ungroup',
'selection.createComponent',
'selection.createComponentSet',
'selection.detachInstance',
'selection.wrapInAutoLayout',
'selection.bringToFront',
'selection.sendToBack',
'view.zoom100',
'view.zoomFit',
'view.zoomSelection'
])
export { importFileDialog, openFileDialog }
export { openFileFromPath } from '@/app/shell/menu/files'
function execBrowserCommand(command: 'copy' | 'paste'): void {
document.execCommand(command)
}
function alignSelected(axis: 'horizontal' | 'vertical', align: 'min' | 'center' | 'max'): void {
store.alignNodes([...store.state.selectedIds], axis, align)
}
function updateSelectedText(updates: {
fontWeight?: number
italic?: boolean
textDecoration?: 'NONE' | 'UNDERLINE'
}): void {
for (const node of store.selectedNodes.value) {
if (node.type === 'TEXT') store.updateNodeWithUndo(node.id, updates, 'Format text')
}
}
export function useMenu() {
if (!IS_TAURI) return
let unlisten: (() => void) | undefined
const { setTheme } = useAppTheme()
const themeActions: Partial<Record<string, () => void>> = {
'theme-dark': () => setTheme('dark'),
const { runCommand } = useEditorCommands()
const actions: Partial<Record<string, () => void>> = {
new: () => createTab(),
open: () => void openFileDialog(),
close: () => {
if (activeTab.value) closeTab(activeTab.value.id)
},
save: () => void store.saveFigFile(),
'save-as': () => void store.saveFigFileAs(),
'export-selection': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
},
'export-png': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
},
'export-svg': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'svg')
},
'export-fig': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'fig')
},
autosave: () => {
store.state.autosaveEnabled = !store.state.autosaveEnabled
},
copy: () => execBrowserCommand('copy'),
paste: () => execBrowserCommand('paste'),
'zoom-in': () => store.applyZoom(-100, window.innerWidth / 2, window.innerHeight / 2),
'zoom-out': () => store.applyZoom(100, window.innerWidth / 2, window.innerHeight / 2),
'theme-light': () => setTheme('light'),
'theme-auto': () => setTheme('auto')
'theme-dark': () => setTheme('dark'),
'theme-auto': () => setTheme('auto'),
'toggle-ui': () => {
store.state.showUI = !store.state.showUI
},
'text.bold': () => {
const node = store.selectedNodes.value.find((item) => item.type === 'TEXT')
updateSelectedText({ fontWeight: node && node.fontWeight >= 700 ? 400 : 700 })
},
'text.italic': () => {
const node = store.selectedNodes.value.find((item) => item.type === 'TEXT')
updateSelectedText({ italic: node ? !node.italic : true })
},
'text.underline': () => {
const node = store.selectedNodes.value.find((item) => item.type === 'TEXT')
updateSelectedText({
textDecoration: node?.textDecoration === 'UNDERLINE' ? 'NONE' : 'UNDERLINE'
})
},
'align-left': () => alignSelected('horizontal', 'min'),
'align-center': () => alignSelected('horizontal', 'center'),
'align-right': () => alignSelected('horizontal', 'max'),
'align-top': () => alignSelected('vertical', 'min'),
'align-middle': () => alignSelected('vertical', 'center'),
'align-bottom': () => alignSelected('vertical', 'max')
}
void import('@tauri-apps/api/event').then(({ listen }) => {
void listen<string>('menu-event', (event) => {
const action = MENU_ACTIONS[event.payload] ?? themeActions[event.payload]
if (action) action()
if (COMMAND_MENU_IDS.has(event.payload)) {
runCommand(event.payload as EditorCommandId)
return
}
actions[event.payload]?.()
}).then((fn) => {
unlisten = fn
})