diff --git a/packages/vue/src/commands/useEditorCommands.ts b/packages/vue/src/commands/useEditorCommands.ts new file mode 100644 index 000000000..ec0da0db8 --- /dev/null +++ b/packages/vue/src/commands/useEditorCommands.ts @@ -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 + 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 = { + '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 + } +} diff --git a/packages/vue/src/commands/useMenuModel.ts b/packages/vue/src/commands/useMenuModel.ts new file mode 100644 index 000000000..237a27adf --- /dev/null +++ b/packages/vue/src/commands/useMenuModel.ts @@ -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(() => [ + commandMenuItem('edit.undo'), + commandMenuItem('edit.redo'), + { separator: true }, + commandMenuItem('selection.duplicate'), + commandMenuItem('selection.delete'), + { separator: true }, + commandMenuItem('selection.selectAll') + ]) + + const viewMenu = computed(() => [ + commandMenuItem('view.zoom100'), + commandMenuItem('view.zoomFit'), + commandMenuItem('view.zoomSelection') + ]) + + const objectMenu = computed(() => [ + 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(() => [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(() => { + 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 + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 006e5e72f..6c5791432 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -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' diff --git a/packages/vue/src/selection/useSelectionCapabilities.ts b/packages/vue/src/selection/useSelectionCapabilities.ts new file mode 100644 index 000000000..998e3aaaf --- /dev/null +++ b/packages/vue/src/selection/useSelectionCapabilities.ts @@ -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 + } +} diff --git a/packages/vue/src/shared/useCanvas.ts b/packages/vue/src/shared/useCanvas.ts index 03051b898..bed873f79 100644 --- a/packages/vue/src/shared/useCanvas.ts +++ b/packages/vue/src/shared/useCanvas.ts @@ -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 diff --git a/packages/vue/src/viewport/useViewportKind.ts b/packages/vue/src/viewport/useViewportKind.ts new file mode 100644 index 000000000..68b9a1d65 --- /dev/null +++ b/packages/vue/src/viewport/useViewportKind.ts @@ -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 + } +} diff --git a/src/components/AppMenu.vue b/src/components/AppMenu.vue index b3a8397a9..ba6401008 100644 --- a/src/components/AppMenu.vue +++ b/src/components/AppMenu.vue @@ -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 } ] @@ -228,24 +242,24 @@ const topMenus = [ :class="menuContent({ class: 'min-w-52' })" >