Extract Vue SDK commands and menu model

This commit is contained in:
Danila Poyarkov 2026-03-23 11:15:29 +03:00
parent b9b960f6ef
commit 0c30d044b2
13 changed files with 636 additions and 249 deletions

View file

@ -0,0 +1,230 @@
import { computed } from 'vue'
import { useEditor } from '../shared/editorContext'
import { usePageList } from '../shared/usePageList'
import { useSelectionCapabilities } from '../selection/useSelectionCapabilities'
import { useSelectionState } from '../shared/useSelectionState'
import type { Component, ComputedRef } from 'vue'
export type EditorCommandId =
| 'edit.undo'
| 'edit.redo'
| 'selection.selectAll'
| 'selection.duplicate'
| 'selection.delete'
| 'selection.group'
| 'selection.ungroup'
| 'selection.createComponent'
| 'selection.createComponentSet'
| 'selection.createInstance'
| 'selection.detachInstance'
| 'selection.goToMainComponent'
| 'selection.wrapInAutoLayout'
| 'selection.bringToFront'
| 'selection.sendToBack'
| 'selection.toggleVisibility'
| 'selection.toggleLock'
| 'selection.moveToPage'
| 'view.zoom100'
| 'view.zoomFit'
| 'view.zoomSelection'
export interface EditorCommand {
id: EditorCommandId
label: string
enabled: ComputedRef<boolean>
run: () => void
}
export interface EditorCommandMenuItem {
label: string
shortcut?: string
action?: () => void
disabled?: boolean
checked?: boolean
onCheckedChange?: (checked: boolean) => void
icon?: Component
}
export interface EditorCommandMenuSeparator {
separator: true
}
export type EditorCommandMenuEntry = EditorCommandMenuItem | EditorCommandMenuSeparator
export function useEditorCommands() {
const editor = useEditor()
const selection = useSelectionState()
const capabilities = useSelectionCapabilities()
const { pages } = usePageList()
const commands: Record<EditorCommandId, EditorCommand> = {
'edit.undo': {
id: 'edit.undo',
label: 'Undo',
enabled: capabilities.canUndo,
run: () => editor.undoAction()
},
'edit.redo': {
id: 'edit.redo',
label: 'Redo',
enabled: capabilities.canRedo,
run: () => editor.redoAction()
},
'selection.selectAll': {
id: 'selection.selectAll',
label: 'Select all',
enabled: capabilities.canSelectAll,
run: () => editor.selectAll()
},
'selection.duplicate': {
id: 'selection.duplicate',
label: 'Duplicate',
enabled: capabilities.canDuplicate,
run: () => editor.duplicateSelected()
},
'selection.delete': {
id: 'selection.delete',
label: 'Delete',
enabled: capabilities.canDelete,
run: () => editor.deleteSelected()
},
'selection.group': {
id: 'selection.group',
label: 'Group',
enabled: capabilities.canGroup,
run: () => editor.groupSelected()
},
'selection.ungroup': {
id: 'selection.ungroup',
label: 'Ungroup',
enabled: capabilities.canUngroup,
run: () => editor.ungroupSelected()
},
'selection.createComponent': {
id: 'selection.createComponent',
label: 'Create component',
enabled: capabilities.canCreateComponent,
run: () => editor.createComponentFromSelection()
},
'selection.createComponentSet': {
id: 'selection.createComponentSet',
label: 'Create component set',
enabled: capabilities.canCreateComponentSet,
run: () => editor.createComponentSetFromComponents()
},
'selection.createInstance': {
id: 'selection.createInstance',
label: 'Create instance',
enabled: capabilities.canCreateInstance,
run: () => {
const node = selection.selectedNode.value
if (node?.type === 'COMPONENT') editor.createInstanceFromComponent(node.id)
}
},
'selection.detachInstance': {
id: 'selection.detachInstance',
label: 'Detach instance',
enabled: capabilities.canDetachInstance,
run: () => editor.detachInstance()
},
'selection.goToMainComponent': {
id: 'selection.goToMainComponent',
label: 'Go to main component',
enabled: capabilities.canGoToMainComponent,
run: () => editor.goToMainComponent()
},
'selection.wrapInAutoLayout': {
id: 'selection.wrapInAutoLayout',
label: 'Add auto layout',
enabled: capabilities.canWrapInAutoLayout,
run: () => editor.wrapInAutoLayout()
},
'selection.bringToFront': {
id: 'selection.bringToFront',
label: 'Bring to front',
enabled: capabilities.canBringToFront,
run: () => editor.bringToFront()
},
'selection.sendToBack': {
id: 'selection.sendToBack',
label: 'Send to back',
enabled: capabilities.canSendToBack,
run: () => editor.sendToBack()
},
'selection.toggleVisibility': {
id: 'selection.toggleVisibility',
label: 'Toggle visibility',
enabled: capabilities.canToggleVisibility,
run: () => editor.toggleVisibility()
},
'selection.toggleLock': {
id: 'selection.toggleLock',
label: 'Toggle lock',
enabled: capabilities.canToggleLock,
run: () => editor.toggleLock()
},
'selection.moveToPage': {
id: 'selection.moveToPage',
label: 'Move to page',
enabled: capabilities.canMoveToPage,
run: () => {}
},
'view.zoom100': {
id: 'view.zoom100',
label: 'Zoom to 100%',
enabled: computed(() => true),
run: () => editor.zoomTo100()
},
'view.zoomFit': {
id: 'view.zoomFit',
label: 'Zoom to fit',
enabled: computed(() => true),
run: () => editor.zoomToFit()
},
'view.zoomSelection': {
id: 'view.zoomSelection',
label: 'Zoom to selection',
enabled: capabilities.canZoomToSelection,
run: () => editor.zoomToSelection()
}
}
const otherPages = computed(() => pages.value.filter((page) => page.id !== editor.state.currentPageId))
function getCommand(id: EditorCommandId) {
return commands[id]
}
function runCommand(id: EditorCommandId) {
const command = commands[id]
if (command.enabled.value) command.run()
}
function moveSelectionToPage(pageId: string) {
if (!capabilities.canMoveToPage.value) return
editor.moveToPage(pageId)
}
function menuItem(id: EditorCommandId, shortcut?: string): EditorCommandMenuItem {
const command = getCommand(id)
return {
label: command.label,
shortcut,
get disabled() {
return !command.enabled.value
},
action: () => runCommand(id)
}
}
return {
commands,
otherPages,
getCommand,
runCommand,
moveSelectionToPage,
menuItem
}
}

View file

@ -0,0 +1,120 @@
import { computed } from 'vue'
import { useEditorCommands } from './useEditorCommands'
import { useSelectionState } from '../shared/useSelectionState'
import { useEditor } from '../shared/editorContext'
export interface MenuActionNode {
separator?: false
label: string
shortcut?: string
action?: () => void
disabled?: boolean
checked?: boolean
onCheckedChange?: (checked: boolean) => void
sub?: MenuEntry[]
}
export interface MenuSeparatorNode {
separator: true
}
export type MenuEntry = MenuActionNode | MenuSeparatorNode
export function useMenuModel() {
const editor = useEditor()
const { menuItem: commandMenuItem, otherPages, moveSelectionToPage } = useEditorCommands()
const { hasSelection, isGroup, isInstance, isComponent, canCreateComponentSet, selectedNode } =
useSelectionState()
const editMenu = computed<MenuEntry[]>(() => [
commandMenuItem('edit.undo'),
commandMenuItem('edit.redo'),
{ separator: true },
commandMenuItem('selection.duplicate'),
commandMenuItem('selection.delete'),
{ separator: true },
commandMenuItem('selection.selectAll')
])
const viewMenu = computed<MenuEntry[]>(() => [
commandMenuItem('view.zoom100'),
commandMenuItem('view.zoomFit'),
commandMenuItem('view.zoomSelection')
])
const objectMenu = computed<MenuEntry[]>(() => [
commandMenuItem('selection.group'),
commandMenuItem('selection.ungroup'),
{ separator: true },
commandMenuItem('selection.createComponent'),
commandMenuItem('selection.createComponentSet'),
commandMenuItem('selection.detachInstance'),
{ separator: true },
commandMenuItem('selection.bringToFront'),
commandMenuItem('selection.sendToBack')
])
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 }
])
const canvasMenu = computed<MenuEntry[]>(() => {
const moveToPageSubmenu: MenuEntry[] = otherPages.value.map((page) => ({
label: page.name,
action: () => moveSelectionToPage(page.id)
}))
return [
{ ...commandMenuItem('selection.duplicate', '⌘D'), label: 'Duplicate' },
commandMenuItem('selection.delete', '⌫'),
{ separator: true },
...(moveToPageSubmenu.length > 0 && hasSelection.value
? [{ label: 'Move to page', sub: moveToPageSubmenu } satisfies MenuActionNode]
: []),
commandMenuItem('selection.bringToFront', ']'),
commandMenuItem('selection.sendToBack', '['),
{ separator: true },
commandMenuItem('selection.group', '⌘G'),
...(isGroup.value ? [commandMenuItem('selection.ungroup', '⇧⌘G')] : []),
...(hasSelection.value ? [commandMenuItem('selection.wrapInAutoLayout', '⇧A')] : []),
{ separator: true },
commandMenuItem('selection.createComponent', '⌥⌘K'),
...(canCreateComponentSet.value ? [commandMenuItem('selection.createComponentSet', '⇧⌘K')] : []),
...(isComponent.value && selectedNode.value
? [
{
label: 'Create instance',
action: () => commandMenuItem('selection.createInstance').action?.(),
disabled: !commandMenuItem('selection.createInstance').disabled
} satisfies MenuActionNode
]
: []),
...(isInstance.value ? [commandMenuItem('selection.goToMainComponent')] : []),
...(isInstance.value ? [commandMenuItem('selection.detachInstance', '⌥⌘B')] : []),
...(hasSelection.value
? [
{ separator: true } as MenuSeparatorNode,
commandMenuItem('selection.toggleVisibility', '⇧⌘H'),
commandMenuItem('selection.toggleLock', '⇧⌘L')
]
: [])
]
})
const selectionLabelMenu = computed(() => ({
visibility: editor.getSelectedNode()?.visible ?? true ? 'Hide' : 'Show',
lock: editor.getSelectedNode()?.locked ?? false ? 'Unlock' : 'Lock'
}))
return {
appMenu,
canvasMenu,
selectionLabelMenu
}
}

View file

@ -12,6 +12,12 @@ export { useNodeProps, MIXED } from './shared/useNodeProps'
export type { MixedValue } from './shared/useNodeProps'
export { useSceneComputed } from './shared/useSceneComputed'
export { useSelectionState } from './shared/useSelectionState'
export { useSelectionCapabilities } from './selection/useSelectionCapabilities'
export { useEditorCommands } from './commands/useEditorCommands'
export type { EditorCommand, EditorCommandId } from './commands/useEditorCommands'
export { useMenuModel } from './commands/useMenuModel'
export type { MenuEntry } from './commands/useMenuModel'
export { useViewportKind } from './viewport/useViewportKind'
export { useLayerDrag } from './shared/useLayerDrag'
export { useInlineRename } from './shared/useInlineRename'
export { useNodeFontStatus } from './shared/useFontStatus'

View file

@ -0,0 +1,67 @@
import { computed } from 'vue'
import { useSelectionState } from '../shared/useSelectionState'
export function useSelectionCapabilities() {
const {
editor,
selectedIds,
selectedNode,
selectedCount,
hasSelection,
isInstance,
isGroup,
canCreateComponentSet
} = useSelectionState()
const canCopy = computed(() => hasSelection.value)
const canCut = computed(() => hasSelection.value)
const canDelete = computed(() => hasSelection.value)
const canDuplicate = computed(() => hasSelection.value)
const canExportSelection = computed(() => hasSelection.value)
const canGroup = computed(() => selectedCount.value >= 2)
const canUngroup = computed(() => isGroup.value)
const canCreateComponent = computed(() => hasSelection.value)
const canDetachInstance = computed(() => isInstance.value)
const canWrapInAutoLayout = computed(() => hasSelection.value)
const canBringToFront = computed(() => hasSelection.value)
const canSendToBack = computed(() => hasSelection.value)
const canToggleVisibility = computed(() => hasSelection.value)
const canToggleLock = computed(() => hasSelection.value)
const canGoToMainComponent = computed(() => isInstance.value)
const canCreateInstance = computed(() => selectedNode.value?.type === 'COMPONENT')
const canMoveToPage = computed(() => hasSelection.value && editor.graph.getPages().length > 1)
const canPaste = computed(() => true)
const canSelectAll = computed(() => editor.graph.getChildren(editor.state.currentPageId).length > 0)
const canUndo = computed(() => editor.undo.canUndo)
const canRedo = computed(() => editor.undo.canRedo)
const canZoomToSelection = computed(() => hasSelection.value)
return {
selectedIds,
selectedNode,
canCopy,
canCut,
canPaste,
canDelete,
canDuplicate,
canExportSelection,
canGroup,
canUngroup,
canCreateComponent,
canCreateComponentSet,
canDetachInstance,
canWrapInAutoLayout,
canBringToFront,
canSendToBack,
canToggleVisibility,
canToggleLock,
canGoToMainComponent,
canCreateInstance,
canMoveToPage,
canSelectAll,
canUndo,
canRedo,
canZoomToSelection
}
}

View file

@ -1,6 +1,8 @@
import { useBreakpoints, useRafFn, useResizeObserver } from '@vueuse/core'
import { useRafFn, useResizeObserver } from '@vueuse/core'
import { onMounted, onScopeDispose, type Ref } from 'vue'
import { useViewportKind } from '../viewport/useViewportKind'
import { getCanvasKit, getGpuBackend, SkiaRenderer } from '@open-pencil/core'
import type { Editor } from '@open-pencil/core/editor'
@ -133,8 +135,7 @@ export function useCanvas(
const params = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams()
const noRulersParam = params.has('no-rulers')
const breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
const { isMobile } = useViewportKind()
function shouldShowRulers() {
if (options?.showRulers === false) return false

View file

@ -0,0 +1,14 @@
import { useBreakpoints } from '@vueuse/core'
import { computed } from 'vue'
const breakpoints = useBreakpoints({ mobile: 768 })
export function useViewportKind() {
const isMobile = breakpoints.smaller('mobile')
const isDesktop = computed(() => !isMobile.value)
return {
isMobile,
isDesktop
}
}

View file

@ -18,13 +18,15 @@ import IconChevronRight from '~icons/lucide/chevron-right'
import { computed } from 'vue'
import { useInlineRename } from '@open-pencil/vue'
import { useEditorCommands, useInlineRename, useMenuModel } from '@open-pencil/vue'
import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
import { IS_TAURI } from '@/constants'
import { openFileDialog } from '@/composables/use-menu'
import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
const { menuItem: commandMenuItem } = useEditorCommands()
const { appMenu } = useMenuModel()
const DOCUMENT_NAME_ID = 'document-name'
const rename = useInlineRename<'document-name'>((_id, name) => {
@ -95,88 +97,100 @@ const fileMenu: MenuEntry[] = [
}
]
const editMenu: MenuEntry[] = [
{ label: 'Undo', shortcut: `${mod}Z`, action: () => store.undoAction() },
{ label: 'Redo', shortcut: `${mod}⇧Z`, action: () => store.redoAction() },
{ separator: true },
{ label: 'Copy', shortcut: `${mod}C` },
{ label: 'Paste', shortcut: `${mod}V` },
{ label: 'Duplicate', shortcut: `${mod}D`, action: () => store.duplicateSelected() },
{ label: 'Delete', shortcut: '⌫', action: () => store.deleteSelected() },
{ separator: true },
{ label: 'Select all', shortcut: `${mod}A`, action: () => store.selectAll() }
]
const viewMenu: MenuEntry[] = [
{ label: 'Zoom to 100%', shortcut: `${mod}0`, action: () => store.zoomTo100() },
{ label: 'Zoom to fit', shortcut: `${mod}1`, action: () => store.zoomToFit() },
{ label: 'Zoom to selection', shortcut: `${mod}2`, action: () => store.zoomToSelection() },
{
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 },
{
label: 'Performance profiler',
get checked() {
return store.renderer?.profiler.hudVisible ?? false
},
onCheckedChange: () => {
store.toggleProfiler()
}
}
]
const objectMenu: MenuEntry[] = [
{ label: 'Group', shortcut: `${mod}G`, action: () => store.groupSelected() },
{ label: 'Ungroup', shortcut: `${mod}⇧G`, action: () => store.ungroupSelected() },
{ separator: true },
{
label: 'Create component',
shortcut: `${mod}⌥K`,
action: () => store.createComponentFromSelection()
},
{
label: 'Create component set',
action: () => store.createComponentSetFromComponents()
},
{ label: 'Detach instance', action: () => store.detachInstance() },
{ separator: true },
{ label: 'Bring to front', shortcut: ']', action: () => store.bringToFront() },
{ label: 'Send to back', shortcut: '[', action: () => store.sendToBack() }
]
const textMenu: MenuEntry[] = [
{ label: 'Bold', shortcut: `${mod}B` },
{ label: 'Italic', shortcut: `${mod}I` },
{ label: 'Underline', shortcut: `${mod}U` }
]
const arrangeMenu: MenuEntry[] = [
{ label: 'Add auto layout', shortcut: '⇧A', action: () => store.wrapInAutoLayout() },
{ separator: true },
{ label: 'Align left', shortcut: '⌥A' },
{ label: 'Align center', shortcut: '⌥H' },
{ label: 'Align right', shortcut: '⌥D' },
{ separator: true },
{ label: 'Align top', shortcut: '⌥W' },
{ label: 'Align middle', shortcut: '⌥V' },
{ label: 'Align bottom', shortcut: '⌥S' }
]
const topMenus = [
{ label: 'File', items: fileMenu },
{ label: 'Edit', items: editMenu },
{ label: 'View', items: viewMenu },
{ label: 'Object', items: objectMenu },
{ label: 'Text', items: textMenu },
{ label: 'Arrange', items: arrangeMenu }
...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 },
{
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 },
{ label: 'Copy', shortcut: `${mod}C` },
{ label: 'Paste', shortcut: `${mod}V` },
commandMenuItem('selection.duplicate', `${mod}D`),
commandMenuItem('selection.delete', '⌫'),
{ separator: true },
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 },
commandMenuItem('selection.createComponent', `${mod}⌥K`),
commandMenuItem('selection.createComponentSet'),
commandMenuItem('selection.detachInstance'),
{ separator: true },
commandMenuItem('selection.bringToFront', ']'),
commandMenuItem('selection.sendToBack', '[')
]
}
}
if (menu.label === 'Arrange') {
return {
label: 'Arrange',
items: [
commandMenuItem('selection.wrapInAutoLayout', '⇧A'),
{ separator: true },
{ label: 'Align left', shortcut: '⌥A' },
{ label: 'Align center', shortcut: '⌥H' },
{ label: 'Align right', shortcut: '⌥D' },
{ separator: true },
{ label: 'Align top', shortcut: '⌥W' },
{ label: 'Align middle', shortcut: '⌥V' },
{ label: 'Align bottom', shortcut: '⌥S' }
]
}
}
return menu
}),
{ label: 'Text', items: textMenu }
]
</script>
@ -228,24 +242,24 @@ const topMenus = [
:class="menuContent({ class: 'min-w-52' })"
>
<template v-for="(item, i) in menu.items" :key="i">
<MenubarSeparator v-if="item.separator" :class="menuSeparator()" />
<MenubarSub v-else-if="item.sub">
<MenubarSeparator v-if="'separator' in item && item.separator" :class="menuSeparator()" />
<MenubarSub v-else-if="'sub' in item && item.sub">
<MenubarSubTrigger :class="menuItem()">
<span class="flex-1">{{ item.label }}</span>
<span class="flex-1">{{ 'label' in item ? item.label : '' }}</span>
<IconChevronRight class="size-3 text-muted" />
</MenubarSubTrigger>
<MenubarPortal>
<MenubarSubContent :side-offset="4" :class="menuContent({ class: 'min-w-44' })">
<template v-for="(sub, j) in item.sub" :key="j">
<MenubarSeparator v-if="sub.separator" :class="menuSeparator()" />
<template v-for="(sub, j) in ('sub' in item && item.sub ? item.sub : [])" :key="j">
<MenubarSeparator v-if="'separator' in sub && sub.separator" :class="menuSeparator()" />
<MenubarItem
v-else
:class="menuItem()"
:disabled="sub.disabled"
@select="sub.action?.()"
:disabled="'disabled' in sub ? sub.disabled : undefined"
@select="'action' in sub ? sub.action?.() : undefined"
>
<span class="flex-1">{{ sub.label }}</span>
<span v-if="sub.shortcut" class="text-[11px] text-muted">{{
<span class="flex-1">{{ 'label' in sub ? sub.label : '' }}</span>
<span v-if="'shortcut' in sub && sub.shortcut" class="text-[11px] text-muted">{{
sub.shortcut
}}</span>
</MenubarItem>
@ -254,12 +268,12 @@ const topMenus = [
</MenubarPortal>
</MenubarSub>
<MenubarCheckboxItem
v-else-if="item.onCheckedChange"
:model-value="item.checked"
v-else-if="'onCheckedChange' in item && item.onCheckedChange"
:model-value="'checked' in item ? item.checked : undefined"
:class="menuItem()"
@update:model-value="item.onCheckedChange?.($event as boolean)"
>
<span class="flex-1">{{ item.label }}</span>
<span class="flex-1">{{ 'label' in item ? item.label : '' }}</span>
<MenubarItemIndicator class="text-surface">
<icon-lucide-check class="size-3.5" />
</MenubarItemIndicator>
@ -267,11 +281,11 @@ const topMenus = [
<MenubarItem
v-else
:class="menuItem()"
:disabled="item.disabled"
@select="item.action?.()"
:disabled="'disabled' in item ? item.disabled : undefined"
@select="'action' in item ? item.action?.() : undefined"
>
<span class="flex-1">{{ item.label }}</span>
<span v-if="item.shortcut" class="text-[11px] text-muted">{{
<span class="flex-1">{{ 'label' in item ? item.label : '' }}</span>
<span v-if="'shortcut' in item && item.shortcut" class="text-[11px] text-muted">{{
item.shortcut
}}</span>
</MenubarItem>

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
ContextMenuContent,
ContextMenuItem,
@ -10,7 +9,7 @@ import {
ContextMenuPortal
} from 'reka-ui'
import { useClipboard } from '@vueuse/core'
import { useSelectionState } from '@open-pencil/vue'
import { useEditorCommands, useMenuModel, useSelectionState } from '@open-pencil/vue'
import { toast } from '@/utils/toast'
import { useEditorStore } from '@/stores/editor'
@ -19,24 +18,10 @@ import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
const store = useEditorStore()
const { copy } = useClipboard()
const {
editor,
selectedIds,
hasSelection,
selectedCount,
selectedNode,
isInstance,
isComponent,
isGroup,
canCreateComponentSet
} = useSelectionState()
const { editor, selectedIds, hasSelection } = useSelectionState()
const { getCommand } = useEditorCommands()
const { canvasMenu } = useMenuModel()
const isVisible = computed(() => selectedNode.value?.visible ?? true)
const isLocked = computed(() => selectedNode.value?.locked ?? false)
const otherPages = computed(() =>
editor.graph.getPages().filter((p) => p.id !== editor.state.currentPageId)
)
function ids() {
return [...selectedIds.value]
@ -84,101 +69,51 @@ const cls = {
<ContextMenuItem
:class="cls.item"
:disabled="!hasSelection"
@select="editor.duplicateSelected()"
@select="getCommand('selection.duplicate').run()"
>
<span>Duplicate</span><span class="text-[11px] text-muted">⌘D</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="editor.deleteSelected()">
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="getCommand('selection.delete').run()">
<span>Delete</span><span class="text-[11px] text-muted">⌫</span>
</ContextMenuItem>
<ContextMenuSeparator :class="cls.sep" />
<ContextMenuSub v-if="otherPages.length > 0 && hasSelection">
<ContextMenuSubTrigger :class="cls.item">
<span>Move to page</span><span class="text-sm text-muted">›</span>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent :class="cls.menu">
<ContextMenuItem
v-for="page in otherPages"
:key="page.id"
:class="cls.item"
@select="editor.moveToPage(page.id)"
>
{{ page.name }}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="editor.bringToFront()">
<span>Bring to front</span><span class="text-[11px] text-muted">]</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="editor.sendToBack()">
<span>Send to back</span><span class="text-[11px] text-muted">[</span>
</ContextMenuItem>
<ContextMenuSeparator :class="cls.sep" />
<ContextMenuItem
:class="cls.item"
:disabled="selectedCount < 2"
@select="editor.groupSelected()"
>
<span>Group</span><span class="text-[11px] text-muted">⌘G</span>
</ContextMenuItem>
<ContextMenuItem v-if="isGroup" :class="cls.item" @select="editor.ungroupSelected()">
<span>Ungroup</span><span class="text-[11px] text-muted">⇧⌘G</span>
</ContextMenuItem>
<ContextMenuItem v-if="hasSelection" :class="cls.item" @select="editor.wrapInAutoLayout()">
<span>Add auto layout</span><span class="text-[11px] text-muted">⇧A</span>
</ContextMenuItem>
<ContextMenuSeparator :class="cls.sep" />
<ContextMenuItem
:class="cls.component"
:disabled="!hasSelection"
@select="editor.createComponentFromSelection()"
>
<span>Create component</span><span class="text-[11px] text-component/60">⌥⌘K</span>
</ContextMenuItem>
<ContextMenuItem
v-if="canCreateComponentSet"
:class="cls.component"
@select="editor.createComponentSetFromComponents()"
>
<span>Create component set</span><span class="text-[11px] text-component/60">⇧⌘K</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isComponent && selectedNode"
:class="cls.component"
@select="editor.createInstanceFromComponent(selectedNode.id)"
>
<span>Create instance</span>
</ContextMenuItem>
<ContextMenuItem v-if="isInstance" :class="cls.component" @select="editor.goToMainComponent()">
<span>Go to main component</span>
</ContextMenuItem>
<ContextMenuItem v-if="isInstance" :class="cls.item" @select="editor.detachInstance()">
<span>Detach instance</span><span class="text-[11px] text-muted">⌥⌘B</span>
</ContextMenuItem>
<template v-for="(item, i) in canvasMenu" :key="`menu-${i}`">
<ContextMenuSeparator v-if="item.separator" :class="cls.sep" />
<ContextMenuSub v-else-if="item.sub">
<ContextMenuSubTrigger :class="cls.item">
<span>{{ item.label }}</span><span class="text-sm text-muted">›</span>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent :class="cls.menu">
<ContextMenuItem
v-for="(sub, j) in item.sub"
:key="j"
:class="cls.item"
:disabled="sub.separator ? true : sub.disabled"
@select="!sub.separator && sub.action?.()"
>
<template v-if="!sub.separator">
<span class="flex-1">{{ sub.label }}</span>
<span v-if="sub.shortcut" class="text-[11px] text-muted">{{ sub.shortcut }}</span>
</template>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuItem
v-else
:class="item.label.includes('component') || item.label.includes('instance') ? cls.component : cls.item"
:disabled="item.disabled"
@select="item.action?.()"
>
<span class="flex-1">{{ item.label }}</span>
<span v-if="item.shortcut" class="text-[11px]" :class="item.label.includes('component') || item.label.includes('instance') ? 'text-component/60' : 'text-muted'">{{ item.shortcut }}</span>
</ContextMenuItem>
</template>
<template v-if="hasSelection">
<ContextMenuSeparator :class="cls.sep" />
<ContextMenuItem :class="cls.item" @select="editor.toggleVisibility()">
<span>{{ isVisible ? 'Hide' : 'Show' }}</span
><span class="text-[11px] text-muted">⇧⌘H</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" @select="editor.toggleLock()">
<span>{{ isLocked ? 'Unlock' : 'Lock' }}</span
><span class="text-[11px] text-muted">⇧⌘L</span>
</ContextMenuItem>
<ContextMenuSeparator :class="cls.sep" />
<ContextMenuSub>
<ContextMenuSubTrigger :class="cls.item">
<span>Copy/Paste as</span><span class="text-sm text-muted">›</span>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEditor } from '@open-pencil/vue'
import { useSelectionState, useEditorCommands } from '@open-pencil/vue'
import VariablesDialog from './VariablesDialog.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
@ -15,11 +15,11 @@ import StrokeSection from './properties/StrokeSection.vue'
import TypographySection from './properties/TypographySection.vue'
import VariablesSection from './properties/VariablesSection.vue'
const store = useEditor()
const variablesOpen = ref(false)
const node = computed(() => store.getSelectedNode())
const multiCount = computed(() => store.getSelectedNodes().length)
const { selectedNode: node, selectedCount: multiCount } = useSelectionState()
const { getCommand } = useEditorCommands()
const goToMainComponent = getCommand('selection.goToMainComponent')
const detachInstance = getCommand('selection.detachInstance')
const isComponentType = computed(() => {
const t = node.value?.type
return t === 'COMPONENT' || t === 'COMPONENT_SET' || t === 'INSTANCE'
@ -71,14 +71,14 @@ const isComponentType = computed(() => {
<button
data-test-id="design-go-to-component"
class="rounded bg-component/10 px-2 py-1 text-left text-[11px] text-component hover:bg-component/20"
@click="store.goToMainComponent()"
@click="goToMainComponent.run()"
>
Go to Main Component
</button>
<button
data-test-id="design-detach-instance"
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="store.detachInstance()"
@click="detachInstance.run()"
>
Detach Instance
</button>

View file

@ -27,6 +27,7 @@ import { toast } from '@/utils/toast'
import { useEditorStore } from '@/stores/editor'
import Tip from '@/components/Tip.vue'
import { colorToCSS } from '@open-pencil/core'
import { useEditorCommands } from '@open-pencil/vue'
import { toolIcons } from '@/utils/tools'
import { initials } from '@/utils/text'
@ -40,6 +41,7 @@ const { copy } = useClipboard()
const collabState = computed(() => collab?.state.value ?? DEFAULT_COLLAB_STATE)
const collabPeers = computed(() => collab?.remotePeers.value ?? [])
const followingPeer = computed(() => collab?.followingPeer.value ?? null)
const { getCommand } = useEditorCommands()
function onShare() {
if (!collab) return
@ -76,7 +78,7 @@ const menuItems: MenuAction[] = [
label: 'Export…',
action: () => store.exportSelection(1, 'PNG')
},
{ icon: IconZoomIn, label: 'Zoom to fit', action: () => store.zoomToFit() }
{ icon: IconZoomIn, label: 'Zoom to fit', action: () => getCommand('view.zoomFit').run() }
]
const onlineCount = computed(() => collabPeers.value.length + 1)
@ -93,7 +95,7 @@ const onlineCount = computed(() => collabPeers.value.length + 1)
<Tip label="Undo">
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-panel/70 shadow-md backdrop-blur-xl select-none active:bg-hover"
@click="store.undoAction()"
@click="getCommand('edit.undo').run()"
>
<icon-lucide-undo-2 class="size-3.5 text-surface" />
</button>
@ -101,7 +103,7 @@ const onlineCount = computed(() => collabPeers.value.length + 1)
<Tip label="Redo">
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-panel/70 shadow-md backdrop-blur-xl select-none active:bg-hover"
@click="store.redoAction()"
@click="getCommand('edit.redo').run()"
>
<icon-lucide-redo-2 class="size-3.5 text-surface" />
</button>

View file

@ -7,7 +7,6 @@ import {
DropdownMenuItem,
DropdownMenuPortal
} from 'reka-ui'
import { useBreakpoints } from '@vueuse/core'
import { AnimatePresence, motion } from 'motion-v'
import IconChevronDown from '~icons/lucide/chevron-down'
@ -29,13 +28,13 @@ import Tip from '@/components/Tip.vue'
import { ACTION_TOAST_DURATION } from '@/constants'
import { useEditorStore } from '@/stores/editor'
import { toolIcons } from '@/utils/tools'
import { ToolbarRoot, ToolbarItem } from '@open-pencil/vue'
import { ToolbarRoot, ToolbarItem, useEditorCommands, useViewportKind } from '@open-pencil/vue'
import type { Component } from 'vue'
import type { EditorToolDef, Tool } from '@open-pencil/vue'
const store = useEditorStore()
const breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
const { isMobile } = useViewportKind()
const { getCommand } = useEditorCommands()
const toolLabels: Record<Tool, string> = {
SELECT: 'Move',
@ -85,16 +84,16 @@ 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: () => store.duplicateSelected() },
{ icon: IconTrash2, label: 'Delete', action: () => store.deleteSelected() }
{ icon: IconCopyPlus, label: 'Duplicate', action: () => getCommand('selection.duplicate').run() },
{ icon: IconTrash2, label: 'Delete', action: () => getCommand('selection.delete').run() }
]
const arrangeActions: ActionItem[] = [
{ icon: IconArrowUpToLine, label: 'Front', action: () => store.bringToFront() },
{ icon: IconArrowDownToLine, label: 'Back', action: () => store.sendToBack() },
{ icon: IconGroup, label: 'Group', action: () => store.groupSelected() },
{ icon: IconUngroup, label: 'Ungroup', action: () => store.ungroupSelected() },
{ icon: IconLock, label: 'Lock', action: () => store.toggleLock() }
{ icon: IconArrowUpToLine, label: 'Front', action: () => getCommand('selection.bringToFront').run() },
{ icon: IconArrowDownToLine, label: '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() }
]
const CATEGORY_COUNT = 3

View file

@ -1,10 +1,10 @@
import { useBreakpoints, useEventListener, useMagicKeys, whenever } from '@vueuse/core'
import { useEventListener, useMagicKeys, whenever } from '@vueuse/core'
import { computed } from 'vue'
import { useAIChat } from '@/composables/use-chat'
import { TOOL_SHORTCUTS, useEditorStore } from '@/stores/editor'
import { closeTab, createTab, activeTab as activeTabRef } from '@/stores/tabs'
import { extractImageFilesFromClipboard } from '@open-pencil/vue'
import { extractImageFilesFromClipboard, useEditorCommands, useViewportKind } from '@open-pencil/vue'
import { openFileDialog } from './use-menu'
@ -55,8 +55,8 @@ function shouldPreventDefault(e: KeyboardEvent, hasPenState: boolean): boolean {
export function useKeyboard() {
const { activeTab } = useAIChat()
const store = useEditorStore()
const breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
const { isMobile } = useViewportKind()
const { runCommand } = useEditorCommands()
useEventListener(window, 'copy', (e: ClipboardEvent) => {
if (isEditing(e)) return
@ -121,19 +121,19 @@ export function useKeyboard() {
}
// --- Mod + Alt ---
whenever(mod('alt+keyk'), () => store.createComponentFromSelection())
whenever(mod('alt+keyb'), () => store.detachInstance())
whenever(mod('alt+keyk'), () => runCommand('selection.createComponent'))
whenever(mod('alt+keyb'), () => runCommand('selection.detachInstance'))
// --- Mod + Shift ---
whenever(mod('shift+keyk'), () => store.createComponentSetFromComponents())
whenever(mod('shift+keyh'), () => store.toggleVisibility())
whenever(mod('shift+keyl'), () => store.toggleLock())
whenever(mod('shift+keyk'), () => runCommand('selection.createComponentSet'))
whenever(mod('shift+keyh'), () => runCommand('selection.toggleVisibility'))
whenever(mod('shift+keyl'), () => runCommand('selection.toggleLock'))
whenever(mod('shift+keye'), () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'PNG')
})
whenever(mod('shift+keys'), () => store.saveFigFileAs())
whenever(mod('shift+keyg'), () => store.ungroupSelected())
whenever(mod('shift+keyz'), () => store.redoAction())
whenever(mod('shift+keyg'), () => runCommand('selection.ungroup'))
whenever(mod('shift+keyz'), () => runCommand('edit.redo'))
// --- Mod + Key ---
whenever(mod('backslash'), () => {
@ -154,25 +154,25 @@ export function useKeyboard() {
})
whenever(mod('keyn'), () => createTab())
whenever(mod('keyt'), () => createTab())
whenever(mod('keyz'), () => store.undoAction())
whenever(mod('keyy'), () => store.redoAction())
whenever(mod('digit0'), () => store.zoomTo100())
whenever(mod('digit1'), () => store.zoomToFit())
whenever(mod('digit2'), () => store.zoomToSelection())
whenever(mod('keyd'), () => store.duplicateSelected())
whenever(mod('keya'), () => store.selectAll())
whenever(mod('keyz'), () => runCommand('edit.undo'))
whenever(mod('keyy'), () => runCommand('edit.redo'))
whenever(mod('digit0'), () => runCommand('view.zoom100'))
whenever(mod('digit1'), () => runCommand('view.zoomFit'))
whenever(mod('digit2'), () => runCommand('view.zoomSelection'))
whenever(mod('keyd'), () => runCommand('selection.duplicate'))
whenever(mod('keya'), () => runCommand('selection.selectAll'))
whenever(mod('keys'), () => store.saveFigFile())
whenever(mod('keyo'), () => openFileDialog())
whenever(mod('keyg'), () => store.groupSelected())
whenever(mod('keyg'), () => runCommand('selection.group'))
// --- Shift (no mod) ---
whenever(
computed(() => keys['shift+digit1'].value && !keys['meta'].value && !keys['control'].value),
() => store.zoomToFit()
() => runCommand('view.zoomFit')
)
whenever(
computed(() => keys['shift+digit2'].value && !keys['meta'].value && !keys['control'].value),
() => store.zoomToSelection()
() => runCommand('view.zoomSelection')
)
whenever(
computed(() => keys['shift+keya'].value && !keys['meta'].value && !keys['control'].value),
@ -181,7 +181,7 @@ export function useKeyboard() {
if (node?.type === 'FRAME' && store.selectedNodes.value.length === 1) {
store.setLayoutMode(node.id, node.layoutMode === 'NONE' ? 'VERTICAL' : 'NONE')
} else if (store.selectedNodes.value.length > 0) {
store.wrapInAutoLayout()
runCommand('selection.wrapInAutoLayout')
}
}
)
@ -198,10 +198,10 @@ export function useKeyboard() {
)
}
whenever(plain('bracketright'), () => store.bringToFront())
whenever(plain('bracketleft'), () => store.sendToBack())
whenever(plain('backspace'), () => store.deleteSelected())
whenever(plain('delete'), () => store.deleteSelected())
whenever(plain('bracketright'), () => runCommand('selection.bringToFront'))
whenever(plain('bracketleft'), () => runCommand('selection.sendToBack'))
whenever(plain('backspace'), () => runCommand('selection.delete'))
whenever(plain('delete'), () => runCommand('selection.delete'))
whenever(plain('enter'), () => {
if (store.state.penState) store.penCommit(false)
})

View file

@ -1,11 +1,11 @@
<script setup lang="ts">
import { onMounted, onUnmounted, provide, ref } from 'vue'
import { useBreakpoints, useEventListener, useUrlSearchParams } from '@vueuse/core'
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { useHead } from '@unhead/vue'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { provideEditor } from '@open-pencil/vue'
import { provideEditor, useViewportKind } from '@open-pencil/vue'
import { useKeyboard } from '@/composables/use-keyboard'
import { useMenu } from '@/composables/use-menu'
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
@ -33,8 +33,7 @@ const showChrome = !('no-chrome' in params)
const firstTab = createTab()
const store = useEditorStore()
provideEditor(store)
const breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
const { isMobile } = useViewportKind()
if (route.meta.demo && !('test' in params)) {
createDemoShapes(firstTab.store)