Rename NodeContextMenuContent → CanvasMenu, extract logic

- Rename to CanvasMenu.vue (was NodeContextMenuContent — terrible name)
- Move copy-as-text/SVG/JSX to core editor clipboard module
- Add useSelectionState() composable to SDK — selection-derived computeds
  (hasSelection, selectedNode, isInstance, isComponent, etc.)
- Eliminate 7x 'void store.state.sceneVersion' reactivity hacks
- Component is now mostly declarative template with menu items
This commit is contained in:
Danila Poyarkov 2026-03-17 08:52:32 +03:00
parent 492d9dfa75
commit aa805edf4d
8 changed files with 280 additions and 342 deletions

View file

@ -7,6 +7,8 @@ import {
} from '../clipboard'
import { collectFontKeys } from '../fonts'
import { computeImageHash } from '../figma-api'
import { renderNodesToSVG } from '../svg-export'
import { selectionToJSX } from '../render'
import type { Fill, SceneGraph, SceneNode } from '../scene-graph'
import type { Vector } from '../types'
@ -279,6 +281,19 @@ export function createClipboardActions(ctx: EditorContext) {
}
}
function copySelectionAsText(ids: string[]): string {
return ids.map((id) => ctx.graph.getNode(id)?.name ?? id).join('\n')
}
function copySelectionAsSVG(ids: string[]): string | null {
const nodeIds = ids.length > 0 ? ids : ctx.graph.getChildren(ctx.state.currentPageId).map((n) => n.id)
return renderNodesToSVG(ctx.graph, ctx.state.currentPageId, nodeIds)
}
function copySelectionAsJSX(ids: string[]): string | null {
return ids.length > 0 ? selectionToJSX(ids, ctx.graph) : null
}
return {
collectSubtrees,
centerNodesAt,
@ -289,6 +304,9 @@ export function createClipboardActions(ctx: EditorContext) {
warnMissingImages,
deleteSelected,
storeImage,
placeImageFiles
placeImageFiles,
copySelectionAsText,
copySelectionAsSVG,
copySelectionAsJSX
}
}

View file

@ -254,6 +254,9 @@ export function createEditor(options?: EditorOptions) {
storeImage: clipboard.storeImage,
placeImageFiles: clipboard.placeImageFiles,
loadFontsForNodes: clipboard.loadFontsForNodes,
copySelectionAsText: clipboard.copySelectionAsText,
copySelectionAsSVG: clipboard.copySelectionAsSVG,
copySelectionAsJSX: clipboard.copySelectionAsJSX,
// Components — bridge functions
createComponentFromSelection: () =>

View file

@ -0,0 +1,50 @@
import { computed } from 'vue'
import { useEditor } from '../context'
import type { SceneNode } from '@open-pencil/core'
export function useSelectionState() {
const editor = useEditor()
const selectedIds = computed(() => {
void editor.state.sceneVersion
return editor.state.selectedIds
})
const hasSelection = computed(() => selectedIds.value.size > 0)
const selectedNode = computed<SceneNode | null>(() => {
void editor.state.sceneVersion
return editor.getSelectedNode() ?? null
})
const selectedCount = computed(() => selectedIds.value.size)
const selectedNodeType = computed(() => selectedNode.value?.type ?? null)
const isInstance = computed(() => selectedNodeType.value === 'INSTANCE')
const isComponent = computed(() => selectedNodeType.value === 'COMPONENT')
const isGroup = computed(() => selectedNodeType.value === 'GROUP')
const canCreateComponentSet = computed(() => {
if (selectedIds.value.size < 2) return false
for (const id of selectedIds.value) {
if (editor.graph.getNode(id)?.type !== 'COMPONENT') return false
}
return true
})
return {
editor,
selectedIds,
hasSelection,
selectedNode,
selectedCount,
selectedNodeType,
isInstance,
isComponent,
isGroup,
canCreateComponentSet
}
}

View file

@ -13,6 +13,7 @@ export { useNodeProps, useNodeProps as useMultiProps, MIXED } from './composable
export type { MixedValue } from './composables/use-node-props'
export { useInlineRename } from './composables/use-inline-rename'
export { useLayerDrag } from './composables/use-layer-drag'
export { useSelectionState } from './composables/use-selection-state'
export { useNodeFontStatus } from './composables/use-font-status'
export { toast } from './toast'
export type { Toast, ToastVariant } from './toast'

View file

@ -0,0 +1,203 @@
<script setup lang="ts">
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuPortal
} from 'reka-ui'
import { useSelectionState, toast } from '@open-pencil/vue'
import { computed } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
const store = useEditorStore()
const {
hasSelection,
selectedNode,
selectedCount,
isInstance,
isComponent,
isGroup,
canCreateComponentSet
} = useSelectionState()
const otherPages = computed(() =>
store.graph.getPages().filter((p) => p.id !== store.state.currentPageId)
)
const isVisible = computed(() => selectedNode.value?.visible ?? true)
const isLocked = computed(() => selectedNode.value?.locked ?? false)
async function copyAsText() {
const text = store.copySelectionAsText([...store.state.selectedIds])
await navigator.clipboard.writeText(text)
toast.show('Copied as text')
}
async function copyAsSVG() {
const svg = store.copySelectionAsSVG([...store.state.selectedIds])
if (!svg) return
await navigator.clipboard.writeText(svg)
toast.show('Copied as SVG')
}
async function copyAsPNG() {
const data = await store.renderExportImage([...store.state.selectedIds], 2, 'PNG')
if (!data) return
const blob = new Blob([data], { type: 'image/png' })
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
toast.show('Copied as PNG')
}
async function copyAsJSX() {
const jsx = store.copySelectionAsJSX([...store.state.selectedIds])
if (!jsx) return
await navigator.clipboard.writeText(jsx)
toast.show('Copied as JSX')
}
function execCommand(cmd: string) {
window.document.execCommand(cmd)
}
const cls = {
menu: menuContent({
class: 'min-w-56 shadow-[0_8px_30px_rgb(0_0_0/0.4)] animate-in fade-in zoom-in-95'
}),
item: menuItem(),
component: menuItem({ tone: 'component' }),
sep: menuSeparator({ class: 'my-1' })
}
</script>
<template>
<ContextMenuContent :class="cls.menu" :side-offset="2" align="start">
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="execCommand('copy')">
<span>Copy</span><span class="text-[11px] text-muted">⌘C</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="execCommand('cut')">
<span>Cut</span><span class="text-[11px] text-muted">⌘X</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" @select="execCommand('paste')">
<span>Paste here</span><span class="text-[11px] text-muted">⌘V</span>
</ContextMenuItem>
<ContextMenuItem
:class="cls.item"
:disabled="!hasSelection"
@select="store.duplicateSelected()"
>
<span>Duplicate</span><span class="text-[11px] text-muted">⌘D</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="store.deleteSelected()">
<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="store.moveToPage(page.id)"
>
{{ page.name }}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="store.bringToFront()">
<span>Bring to front</span><span class="text-[11px] text-muted">]</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" :disabled="!hasSelection" @select="store.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="store.groupSelected()"
>
<span>Group</span><span class="text-[11px] text-muted">⌘G</span>
</ContextMenuItem>
<ContextMenuItem v-if="isGroup" :class="cls.item" @select="store.ungroupSelected()">
<span>Ungroup</span><span class="text-[11px] text-muted">⇧⌘G</span>
</ContextMenuItem>
<ContextMenuItem v-if="hasSelection" :class="cls.item" @select="store.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="store.createComponentFromSelection()"
>
<span>Create component</span><span class="text-[11px] text-component/60">⌥⌘K</span>
</ContextMenuItem>
<ContextMenuItem
v-if="canCreateComponentSet"
:class="cls.component"
@select="store.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="store.createInstanceFromComponent(selectedNode.id)"
>
<span>Create instance</span>
</ContextMenuItem>
<ContextMenuItem v-if="isInstance" :class="cls.component" @select="store.goToMainComponent()">
<span>Go to main component</span>
</ContextMenuItem>
<ContextMenuItem v-if="isInstance" :class="cls.item" @select="store.detachInstance()">
<span>Detach instance</span><span class="text-[11px] text-muted">⌥⌘B</span>
</ContextMenuItem>
<template v-if="hasSelection">
<ContextMenuSeparator :class="cls.sep" />
<ContextMenuItem :class="cls.item" @select="store.toggleVisibility()">
<span>{{ isVisible ? 'Hide' : 'Show' }}</span
><span class="text-[11px] text-muted">⇧⌘H</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" @select="store.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>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent :class="cls.menu">
<ContextMenuItem :class="cls.item" @select="copyAsText">Copy as text</ContextMenuItem>
<ContextMenuItem :class="cls.item" @select="copyAsSVG">Copy as SVG</ContextMenuItem>
<ContextMenuItem :class="cls.item" @select="copyAsPNG">
<span>Copy as PNG</span><span class="text-[11px] text-muted">⇧⌘C</span>
</ContextMenuItem>
<ContextMenuItem :class="cls.item" @select="copyAsJSX">Copy as JSX</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
</template>
</ContextMenuContent>
</template>

View file

@ -5,7 +5,7 @@ import { ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import { toolCursor, useCanvas, useCanvasDrop, useCanvasInput, useTextEdit } from '@open-pencil/vue'
import { useCollabInjected } from '@/composables/use-collab'
import { useEditorStore } from '@/stores/editor'
import NodeContextMenuContent from './NodeContextMenuContent.vue'
import CanvasMenu from './CanvasMenu.vue'
const store = useEditorStore()
const collab = useCollabInjected()
@ -103,7 +103,7 @@ function onContextMenu(e: MouseEvent) {
</ContextMenuTrigger>
<ContextMenuPortal>
<NodeContextMenuContent />
<CanvasMenu />
</ContextMenuPortal>
</ContextMenuRoot>
</template>

View file

@ -6,7 +6,7 @@ import { useInlineRename, useLayerDrag } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
import LayerRow from './LayerRow.vue'
import NodeContextMenuContent from './NodeContextMenuContent.vue'
import CanvasMenu from './CanvasMenu.vue'
const INDENT_PER_LEVEL = 16
@ -160,7 +160,7 @@ function toggleExpand(id: string) {
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<NodeContextMenuContent />
<CanvasMenu />
</ContextMenuPortal>
</ContextMenuRoot>
</template>

View file

@ -1,337 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuPortal
} from 'reka-ui'
import { selectionToJSX, renderNodesToSVG } from '@open-pencil/core'
import { useEditorStore } from '@/stores/editor'
import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
import { toast } from '@open-pencil/vue'
const store = useEditorStore()
const hasSelection = computed(() => {
void store.state.sceneVersion
return store.state.selectedIds.size > 0
})
const singleNode = computed(() => {
void store.state.sceneVersion
if (store.state.selectedIds.size !== 1) return null
const id = [...store.state.selectedIds][0]
return store.graph.getNode(id) ?? null
})
const multiCount = computed(() => {
void store.state.sceneVersion
return store.state.selectedIds.size
})
const isInstance = computed(() => singleNode.value?.type === 'INSTANCE')
const isComponent = computed(() => singleNode.value?.type === 'COMPONENT')
const isGroup = computed(() => singleNode.value?.type === 'GROUP')
const canCreateComponentSet = computed(() => {
void store.state.sceneVersion
if (store.state.selectedIds.size < 2) return false
return [...store.state.selectedIds].every((id) => {
const n = store.graph.getNode(id)
return n?.type === 'COMPONENT'
})
})
const otherPages = computed(() => {
void store.state.sceneVersion
return store.graph.getPages().filter((p) => p.id !== store.state.currentPageId)
})
const isVisible = computed(() => {
void store.state.sceneVersion
if (!singleNode.value) return true
return singleNode.value.visible
})
const isLocked = computed(() => {
void store.state.sceneVersion
if (!singleNode.value) return false
return singleNode.value.locked
})
function selectedIds(): string[] {
return [...store.state.selectedIds]
}
async function copyAsText() {
const ids = selectedIds()
const names = ids.map((id) => store.graph.getNode(id)?.name ?? id).join('\n')
await navigator.clipboard.writeText(names)
toast.show('Copied as text')
}
async function copyAsSVG() {
const ids = selectedIds()
const svg = renderNodesToSVG(store.graph, store.state.currentPageId, ids)
if (!svg) return
await navigator.clipboard.writeText(svg)
toast.show('Copied as SVG')
}
async function copyAsPNG() {
const ids = selectedIds()
const data = await store.renderExportImage(ids, 2, 'PNG')
if (!data) return
const blob = new Blob([data], { type: 'image/png' })
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
toast.show('Copied as PNG')
}
function copyAsJSX() {
const ids = selectedIds()
const jsx = selectionToJSX(ids, store.graph)
if (!jsx) return
navigator.clipboard.writeText(jsx)
toast.show('Copied as JSX')
}
function execCommand(command: string) {
window.document.execCommand(command)
}
const itemClass = menuItem()
const componentItemClass = menuItem({ tone: 'component' })
const menuClass = menuContent({
class: 'min-w-56 shadow-[0_8px_30px_rgb(0_0_0/0.4)] animate-in fade-in zoom-in-95'
})
const separatorClass = menuSeparator({ class: 'my-1' })
</script>
<template>
<ContextMenuContent :class="menuClass" :side-offset="2" align="start">
<ContextMenuItem
data-test-id="context-copy"
:class="itemClass"
:disabled="!hasSelection"
@select="execCommand('copy')"
>
<span>Copy</span>
<span class="text-[11px] text-muted">⌘C</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-cut"
:class="itemClass"
:disabled="!hasSelection"
@select="execCommand('cut')"
>
<span>Cut</span>
<span class="text-[11px] text-muted">⌘X</span>
</ContextMenuItem>
<ContextMenuItem data-test-id="context-paste" :class="itemClass" @select="execCommand('paste')">
<span>Paste here</span>
<span class="text-[11px] text-muted">⌘V</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-duplicate"
:class="itemClass"
:disabled="!hasSelection"
@select="store.duplicateSelected()"
>
<span>Duplicate</span>
<span class="text-[11px] text-muted">⌘D</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-delete"
:class="itemClass"
:disabled="!hasSelection"
@select="store.deleteSelected()"
>
<span>Delete</span>
<span class="text-[11px] text-muted">⌫</span>
</ContextMenuItem>
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuSub v-if="otherPages.length > 0 && hasSelection">
<ContextMenuSubTrigger data-test-id="context-move-to-page" :class="itemClass">
<span>Move to page</span>
<span class="text-sm text-muted">›</span>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent :class="menuClass">
<ContextMenuItem
v-for="page in otherPages"
:key="page.id"
:class="itemClass"
@select="store.moveToPage(page.id)"
>
{{ page.name }}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuItem
data-test-id="context-bring-to-front"
:class="itemClass"
:disabled="!hasSelection"
@select="store.bringToFront()"
>
<span>Bring to front</span>
<span class="text-[11px] text-muted">]</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-send-to-back"
:class="itemClass"
:disabled="!hasSelection"
@select="store.sendToBack()"
>
<span>Send to back</span>
<span class="text-[11px] text-muted">[</span>
</ContextMenuItem>
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-group"
:class="itemClass"
:disabled="multiCount < 2"
@select="store.groupSelected()"
>
<span>Group</span>
<span class="text-[11px] text-muted">⌘G</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isGroup"
data-test-id="context-ungroup"
:class="itemClass"
@select="store.ungroupSelected()"
>
<span>Ungroup</span>
<span class="text-[11px] text-muted">⇧⌘G</span>
</ContextMenuItem>
<ContextMenuItem
v-if="hasSelection"
data-test-id="context-auto-layout"
:class="itemClass"
@select="store.wrapInAutoLayout()"
>
<span>Add auto layout</span>
<span class="text-[11px] text-muted">⇧A</span>
</ContextMenuItem>
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-create-component"
:class="componentItemClass"
:disabled="!hasSelection"
@select="store.createComponentFromSelection()"
>
<span>Create component</span>
<span class="text-[11px] text-component/60">⌥⌘K</span>
</ContextMenuItem>
<ContextMenuItem
v-if="canCreateComponentSet"
data-test-id="context-create-component-set"
:class="componentItemClass"
@select="store.createComponentSetFromComponents()"
>
<span>Create component set</span>
<span class="text-[11px] text-component/60">⇧⌘K</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isComponent"
data-test-id="context-create-instance"
:class="componentItemClass"
@select="store.createInstanceFromComponent(singleNode!.id)"
>
<span>Create instance</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isInstance"
data-test-id="context-go-to-component"
:class="componentItemClass"
@select="store.goToMainComponent()"
>
<span>Go to main component</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isInstance"
data-test-id="context-detach-instance"
:class="itemClass"
@select="store.detachInstance()"
>
<span>Detach instance</span>
<span class="text-[11px] text-muted">⌥⌘B</span>
</ContextMenuItem>
<template v-if="hasSelection">
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-toggle-visibility"
:class="itemClass"
@select="store.toggleVisibility()"
>
<span>{{ isVisible ? 'Hide' : 'Show' }}</span>
<span class="text-[11px] text-muted">⇧⌘H</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-toggle-lock"
:class="itemClass"
@select="store.toggleLock()"
>
<span>{{ isLocked ? 'Unlock' : 'Lock' }}</span>
<span class="text-[11px] text-muted">⇧⌘L</span>
</ContextMenuItem>
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuSub>
<ContextMenuSubTrigger data-test-id="context-copy-paste-as" :class="itemClass">
<span>Copy/Paste as</span>
<span class="text-sm text-muted">›</span>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent :class="menuClass">
<ContextMenuItem
data-test-id="context-copy-as-text"
:class="itemClass"
@select="copyAsText"
>
Copy as text
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-copy-as-svg"
:class="itemClass"
@select="copyAsSVG"
>
Copy as SVG
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-copy-as-png"
:class="itemClass"
@select="copyAsPNG"
>
<span>Copy as PNG</span>
<span class="text-[11px] text-muted">⇧⌘C</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-copy-as-jsx"
:class="itemClass"
@select="copyAsJSX"
>
Copy as JSX
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
</template>
</ContextMenuContent>
</template>