refactor(app): share browser and tauri menu model
This commit is contained in:
parent
0d16354c89
commit
28d67818e2
|
|
@ -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
288
desktop/generated/menu.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
35
scripts/generate-tauri-menu.ts
Normal file
35
scripts/generate-tauri-menu.ts
Normal 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`)
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
228
src/app/shell/menu/schema.ts
Normal file
228
src/app/shell/menu/schema.ts
Normal 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[]
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue