refactor(vue): centralize canvas menu groups

This commit is contained in:
Danila Poyarkov 2026-05-17 14:09:52 +03:00
parent 5634e0ecb4
commit 29d7ae4817
19 changed files with 221 additions and 158 deletions

View file

@ -172,7 +172,7 @@
},
"overrides": [
{
"files": ["src/**/*.ts"],
"files": ["src/**/*.ts", "packages/*/src/**/*.ts"],
"rules": {
"open-pencil/no-useless-pass-through-wrappers": "error"
}

View file

@ -4,7 +4,7 @@ import type { SceneGraph } from '#core/scene-graph'
import * as React from './mini-react'
import { renderTree, type RenderResult } from './renderer'
import { resolveToTree, type TreeNode } from './tree'
import { resolveToTree } from './tree'
/**
* Build a component function from a JSX string using sucrase.
@ -78,13 +78,4 @@ export async function renderJSX(
return [await renderTree(graph, tree, options)]
}
/**
* Render a pre-built TreeNode into the scene graph.
*/
export async function renderTreeNode(
graph: SceneGraph,
tree: TreeNode,
options?: RenderJSXOptions
): Promise<RenderResult> {
return renderTree(graph, tree, options)
}
export { renderTree as renderTreeNode }

View file

@ -1,7 +1,7 @@
import type { EditorContext } from '#core/editor/types'
import type { SceneNode, VectorNetwork, VectorRegion, VectorSegment } from '#core/scene-graph'
import type { Vector } from '#core/types'
import { computeVectorBounds } from '#core/vector'
import { computeAccurateBounds } from '#core/vector'
export interface PenDragOptions {
keepOpposite?: boolean
@ -175,7 +175,7 @@ export function createPenActions(ctx: EditorContext, createShape: CreateShape) {
regions
}
const bounds = computeVectorBounds(network)
const bounds = computeAccurateBounds(network)
const normalizedVertices = network.vertices.map((v) => ({
...v,

View file

@ -68,15 +68,7 @@ export async function fetchIcons(names: string[], size = 24): Promise<Map<string
return results
}
export async function searchIcons(
query: string,
options?: {
limit?: number
prefix?: string
}
): Promise<IconSearchResult> {
return searchIconify(query, options)
}
export { searchIconify as searchIcons }
export async function searchIconsBatch(
queries: string[],
@ -88,7 +80,7 @@ export async function searchIconsBatch(
const results = new Map<string, IconSearchResult>()
await Promise.all(
queries.map(async (query) => {
const result = await searchIcons(query, options)
const result = await searchIconify(query, options)
results.set(query, result)
})
)

View file

@ -223,8 +223,7 @@ export {
geometryBlobToPath,
decodeVectorNetworkBlob,
encodeVectorNetworkBlob,
buildStyleOverrideTable,
computeVectorBounds
buildStyleOverrideTable
} from './vector'
export {
evalCubic,

View file

@ -22,10 +22,6 @@ import type {
VectorVertex,
WindingRule
} from '#core/scene-graph'
import type { Rect } from '#core/types'
import { computeAccurateBounds } from './bezier'
export { vectorNetworkToCenterlinePath, fitCircleArc, isClosedThinCrescent } from './centerline'
// --- vectorNetworkBlob binary format ---
@ -280,10 +276,6 @@ function addLoopToPath(
path.close()
}
export function computeVectorBounds(network: VectorNetwork): Rect {
return computeAccurateBounds(network)
}
const CMD_CLOSE = 0
const CMD_MOVE_TO = 1
const CMD_LINE_TO = 2

View file

@ -1,15 +1 @@
import type { Editor } from '@open-pencil/core/editor'
import { createTransformInputActions } from '#vue/canvas/transform-input/actions'
import type { DragState } from '#vue/shared/input/types'
type CanvasToLocal = (cx: number, cy: number, scopeId: string) => { lx: number; ly: number }
type SetDrag = (drag: DragState) => void
export function createCanvasTransformInput(
editor: Editor,
canvasToLocal: CanvasToLocal,
setDrag: SetDrag
) {
return createTransformInputActions(editor, canvasToLocal, setDrag)
}
export { createTransformInputActions as createCanvasTransformInput } from '#vue/canvas/transform-input/actions'

View file

@ -5,23 +5,9 @@ import {
OBJECT_MENU_COMMAND_GROUPS,
VIEW_MENU_COMMANDS
} from '#vue/editor/menu-model/command-groups'
import type { MenuActionNode, MenuEntry, MenuSeparatorNode } from '#vue/editor/menu-model/types'
import type { useSelectionState } from '#vue/editor/selection-state/use'
import type { MenuEntry } from '#vue/editor/menu-model/types'
type CommandMenuItem = ReturnType<typeof useEditorCommands>['menuItem']
type SelectionState = ReturnType<typeof useSelectionState>
type MenuTranslations = {
moveToPage: string
}
type CanvasMenuOptions = {
commandMenuItem: CommandMenuItem
otherPages: ReturnType<typeof useEditorCommands>['otherPages']['value']
moveSelectionToPage: ReturnType<typeof useEditorCommands>['moveSelectionToPage']
selection: SelectionState
t: MenuTranslations
}
function commandGroupEntries(
commandMenuItem: CommandMenuItem,
@ -46,47 +32,3 @@ export function buildViewMenu(commandMenuItem: CommandMenuItem): MenuEntry[] {
export function buildObjectMenu(commandMenuItem: CommandMenuItem): MenuEntry[] {
return commandGroupEntries(commandMenuItem, OBJECT_MENU_COMMAND_GROUPS)
}
export function buildCanvasMenu({
commandMenuItem,
otherPages,
moveSelectionToPage,
selection,
t
}: CanvasMenuOptions): MenuEntry[] {
const moveToPageSubmenu: MenuEntry[] = otherPages.map((page) => ({
label: page.name,
action: () => moveSelectionToPage(page.id)
}))
return [
commandMenuItem('selection.duplicate'),
commandMenuItem('selection.delete'),
{ separator: true },
...(moveToPageSubmenu.length > 0 && selection.hasSelection.value
? [{ label: t.moveToPage, sub: moveToPageSubmenu } satisfies MenuActionNode]
: []),
commandMenuItem('selection.bringToFront'),
commandMenuItem('selection.sendToBack'),
{ separator: true },
commandMenuItem('selection.group'),
...(selection.isGroup.value ? [commandMenuItem('selection.ungroup')] : []),
...(selection.hasSelection.value ? [commandMenuItem('selection.wrapInAutoLayout')] : []),
{ separator: true },
...(selection.isComponent.value
? [commandMenuItem('selection.createInstance')]
: [commandMenuItem('selection.createComponent')]),
...(selection.canCreateComponentSet.value
? [commandMenuItem('selection.createComponentSet')]
: []),
...(selection.isInstance.value ? [commandMenuItem('selection.goToMainComponent')] : []),
...(selection.isInstance.value ? [commandMenuItem('selection.detachInstance')] : []),
...(selection.hasSelection.value
? [
{ separator: true } as MenuSeparatorNode,
commandMenuItem('selection.toggleVisibility'),
commandMenuItem('selection.toggleLock')
]
: [])
]
}

View file

@ -0,0 +1,98 @@
import type { EditorCommandId } from '#vue/editor/commands/types'
import type { useEditorCommands } from '#vue/editor/commands/use'
import type { MenuActionNode, MenuEntry, MenuSeparatorNode } from '#vue/editor/menu-model/types'
import type { useSelectionState } from '#vue/editor/selection-state/use'
type CommandMenuItem = ReturnType<typeof useEditorCommands>['menuItem']
type SelectionState = ReturnType<typeof useSelectionState>
type CanvasMenuCommand =
| EditorCommandId
| 'selection.componentAction'
| 'selection.componentSetAction'
| 'selection.instanceActions'
| 'selection.ungroupWhenGroup'
| 'selection.moveToPageWhenAvailable'
type CanvasMenuGroup = readonly CanvasMenuCommand[]
type CanvasMenuTranslations = {
moveToPage: string
}
export type CanvasMenuOptions = {
commandMenuItem: CommandMenuItem
otherPages: ReturnType<typeof useEditorCommands>['otherPages']['value']
moveSelectionToPage: ReturnType<typeof useEditorCommands>['moveSelectionToPage']
selection: SelectionState
t: CanvasMenuTranslations
}
const CANVAS_MENU_GROUPS = [
['selection.duplicate', 'selection.delete'],
['selection.moveToPageWhenAvailable', 'selection.bringToFront', 'selection.sendToBack'],
['selection.group', 'selection.ungroupWhenGroup', 'selection.wrapInAutoLayout'],
['selection.componentAction', 'selection.componentSetAction', 'selection.instanceActions'],
['selection.toggleVisibility', 'selection.toggleLock']
] satisfies readonly CanvasMenuGroup[]
function separator(): MenuSeparatorNode {
return { separator: true }
}
function moveToPageItem({ otherPages, moveSelectionToPage, selection, t }: CanvasMenuOptions) {
if (!selection.hasSelection.value || otherPages.length === 0) return []
const sub = otherPages.map((page) => ({
label: page.name,
action: () => moveSelectionToPage(page.id)
}))
return [{ label: t.moveToPage, sub } satisfies MenuActionNode]
}
function componentItems({ commandMenuItem, selection }: CanvasMenuOptions): MenuEntry[] {
return [
selection.isComponent.value
? commandMenuItem('selection.createInstance')
: commandMenuItem('selection.createComponent')
]
}
function componentSetItems({ commandMenuItem, selection }: CanvasMenuOptions): MenuEntry[] {
return selection.canCreateComponentSet.value
? [commandMenuItem('selection.createComponentSet')]
: []
}
function instanceItems({ commandMenuItem, selection }: CanvasMenuOptions): MenuEntry[] {
return selection.isInstance.value
? [commandMenuItem('selection.goToMainComponent'), commandMenuItem('selection.detachInstance')]
: []
}
function conditionalCommand(command: CanvasMenuCommand, options: CanvasMenuOptions): MenuEntry[] {
switch (command) {
case 'selection.moveToPageWhenAvailable':
return moveToPageItem(options)
case 'selection.componentAction':
return componentItems(options)
case 'selection.componentSetAction':
return componentSetItems(options)
case 'selection.instanceActions':
return instanceItems(options)
case 'selection.ungroupWhenGroup':
return options.selection.isGroup.value ? [options.commandMenuItem('selection.ungroup')] : []
default:
return [options.commandMenuItem(command)]
}
}
export function buildCanvasContextMenu(options: CanvasMenuOptions): MenuEntry[] {
const entries: MenuEntry[] = []
for (const group of CANVAS_MENU_GROUPS) {
const groupEntries = group.flatMap((command) => conditionalCommand(command, options))
if (groupEntries.length === 0) continue
if (entries.length > 0) entries.push(separator())
entries.push(...groupEntries)
}
return entries
}

View file

@ -3,12 +3,8 @@ import { computed } from 'vue'
import { useEditorCommands } from '#vue/editor/commands/use'
import { useEditor } from '#vue/editor/context'
import {
buildCanvasMenu,
buildEditMenu,
buildObjectMenu,
buildViewMenu
} from '#vue/editor/menu-model/builders'
import { buildEditMenu, buildObjectMenu, buildViewMenu } from '#vue/editor/menu-model/builders'
import { buildCanvasContextMenu } from '#vue/editor/menu-model/canvas'
import { useSelectionState } from '#vue/editor/selection-state/use'
import { menuMessages } from '#vue/i18n'
@ -46,7 +42,7 @@ export function useMenuModel() {
])
const canvasMenu = computed<MenuEntry[]>(() =>
buildCanvasMenu({
buildCanvasContextMenu({
commandMenuItem,
otherPages: otherPages.value,
moveSelectionToPage,

View file

@ -115,7 +115,7 @@
"detachVariable": "Variable lösen",
"applyVariable": "Variable anwenden",
"noVariablesFound": "Keine Variablen gefunden",
"addAutoLayout": "Auto-Layout hinzufügen (Shift+A)",
"addAutoLayout": "Auto-Layout hinzufügen",
"removeAutoLayout": "Auto-Layout entfernen",
"mixed": "Gemischt",
"layersCount": "{count} Ebenen",

View file

@ -115,7 +115,7 @@
"detachVariable": "Desvincular variable",
"applyVariable": "Aplicar variable",
"noVariablesFound": "No se encontraron variables",
"addAutoLayout": "Añadir auto-layout (Shift+A)",
"addAutoLayout": "Añadir auto-layout",
"removeAutoLayout": "Quitar auto-layout",
"mixed": "Mixto",
"layersCount": "{count} capas",

View file

@ -115,7 +115,7 @@
"detachVariable": "Détacher la variable",
"applyVariable": "Appliquer la variable",
"noVariablesFound": "Aucune variable trouvée",
"addAutoLayout": "Ajouter un auto-layout (Shift+A)",
"addAutoLayout": "Ajouter un auto-layout",
"removeAutoLayout": "Retirer l'auto-layout",
"mixed": "Mixte",
"layersCount": "{count} calques",

View file

@ -115,7 +115,7 @@
"detachVariable": "Scollega variabile",
"applyVariable": "Applica variabile",
"noVariablesFound": "Nessuna variabile trovata",
"addAutoLayout": "Aggiungi auto-layout (Shift+A)",
"addAutoLayout": "Aggiungi auto-layout",
"removeAutoLayout": "Rimuovi auto-layout",
"mixed": "Misto",
"layersCount": "{count} livelli",

View file

@ -115,7 +115,7 @@
"detachVariable": "Odłącz zmienną",
"applyVariable": "Zastosuj zmienną",
"noVariablesFound": "Nie znaleziono zmiennych",
"addAutoLayout": "Dodaj auto-layout (Shift+A)",
"addAutoLayout": "Dodaj auto-layout",
"removeAutoLayout": "Usuń auto-layout",
"mixed": "Mieszane",
"layersCount": "{count} warstw",

View file

@ -115,7 +115,7 @@
"detachVariable": "Отвязать переменную",
"applyVariable": "Применить переменную",
"noVariablesFound": "Переменные не найдены",
"addAutoLayout": "Добавить автораскладку (Shift+A)",
"addAutoLayout": "Добавить автораскладку",
"removeAutoLayout": "Убрать автораскладку",
"alignLeft": "Выровнять по левому краю",
"alignCenterHorizontally": "Выровнять по центру по горизонтали",

View file

@ -115,7 +115,7 @@
"detachVariable": "解除变量绑定",
"applyVariable": "应用变量",
"noVariablesFound": "未找到变量",
"addAutoLayout": "添加自动布局 (Shift+A)",
"addAutoLayout": "添加自动布局",
"removeAutoLayout": "移除自动布局",
"alignLeft": "左对齐",
"alignCenterHorizontally": "水平居中",

View file

@ -17,6 +17,11 @@ type Rule = { name: string; check: (root: TreeEntry) => RuleResult }
type FileRuleCheck = (sourceRel: string) => string | null
type TextRuleCheck = (
sourceRel: string,
content: string
) => Array<{ message: string; line?: number; column?: number }>
const FILE_PREFIX_GROUP_ALLOWLIST = new Set([
'packages/core/src/lint/rules::no',
'tests/engine::visual'
@ -109,6 +114,26 @@ function resolveImport(sourceRel: string, specifier: string): string | null {
return null
}
function createTextRule(name: string, checkText: TextRuleCheck): Rule {
return {
name,
check(root) {
const diagnostics: Diagnostic[] = []
for (const file of collectFiles(root)) {
const sourceRel = relativePath(root.path, file)
const content = readFileSync(file, 'utf8')
for (const result of checkText(sourceRel, content)) {
diagnostics.push({
message: result.message,
location: { path: file, line: result.line, column: result.column }
})
}
}
return { diagnostics }
}
}
}
function createFileRule(name: string, checkFile: FileRuleCheck): Rule {
return {
name,
@ -185,29 +210,34 @@ const preferDomainFoldersOverFilenamePrefixes: Rule = {
}
}
const strictTestFilePlacement = createFileRule('open-pencil/strict-test-file-placement', (sourceRel) => {
if (!sourceRel.startsWith('tests/')) return null
if (!TEXT_EXTENSIONS.has(path.extname(sourceRel))) return null
const name = path.basename(sourceRel)
if (name.includes('.tmp.') || name.includes('.profile.')) {
return 'Temporary/profile test files must not be committed. Move exploratory specs to scratch/ or delete them.'
const strictTestFilePlacement = createFileRule(
'open-pencil/strict-test-file-placement',
(sourceRel) => {
if (!sourceRel.startsWith('tests/')) return null
if (!TEXT_EXTENSIONS.has(path.extname(sourceRel))) return null
const name = path.basename(sourceRel)
if (name.includes('.tmp.') || name.includes('.profile.')) {
return 'Temporary/profile test files must not be committed. Move exploratory specs to scratch/ or delete them.'
}
if (sourceRel.startsWith('tests/e2e/')) {
if (sourceRel.endsWith('.spec.ts') || sourceRel.endsWith('/fixtures.ts')) return null
return 'E2E tests must live under tests/e2e/** and use *.spec.ts.'
}
if (sourceRel.startsWith('tests/figma/')) {
return sourceRel.endsWith('.spec.ts')
? null
: 'Figma Playwright tests must live under tests/figma/** and use *.spec.ts.'
}
if (sourceRel.startsWith('tests/engine/')) {
if (sourceRel.endsWith('.test.ts')) return null
if (sourceRel.endsWith('/helpers.ts') || sourceRel.endsWith('.bench.ts')) return null
if (/\/visual\/[^/]+\.ts$/.test(sourceRel)) return null
return 'Engine/unit tests must live under tests/engine/** and use *.test.ts; helpers.ts, *.bench.ts, and domain visual support scripts are allowed.'
}
if (sourceRel.startsWith('tests/helpers/')) return null
return 'Tests must live under tests/e2e/** (*.spec.ts), tests/engine/** (*.test.ts), or tests/helpers/**.'
}
if (sourceRel.startsWith('tests/e2e/')) {
if (sourceRel.endsWith('.spec.ts') || sourceRel.endsWith('/fixtures.ts')) return null
return 'E2E tests must live under tests/e2e/** and use *.spec.ts.'
}
if (sourceRel.startsWith('tests/figma/')) {
return sourceRel.endsWith('.spec.ts') ? null : 'Figma Playwright tests must live under tests/figma/** and use *.spec.ts.'
}
if (sourceRel.startsWith('tests/engine/')) {
if (sourceRel.endsWith('.test.ts')) return null
if (sourceRel.endsWith('/helpers.ts') || sourceRel.endsWith('.bench.ts')) return null
if (/\/visual\/[^/]+\.ts$/.test(sourceRel)) return null
return 'Engine/unit tests must live under tests/engine/** and use *.test.ts; helpers.ts, *.bench.ts, and domain visual support scripts are allowed.'
}
if (sourceRel.startsWith('tests/helpers/')) return null
return 'Tests must live under tests/e2e/** (*.spec.ts), tests/engine/** (*.test.ts), or tests/helpers/**.'
})
)
const ENGINE_TEST_DOMAIN_REDIRECTS: Array<{
from: string
@ -295,12 +325,15 @@ const noE2EImportsInEngineTests = createImportRule(
}
)
const noRootMarkdownClutter = createFileRule('open-pencil/no-root-markdown-clutter', (sourceRel) => {
if (sourceRel.includes('/')) return null
if (!sourceRel.endsWith('.md')) return null
if (ROOT_MARKDOWN_ALLOWLIST.has(sourceRel)) return null
return 'Do not add ad hoc root Markdown files. Put durable docs under packages/docs/** or update the root allowlist deliberately.'
})
const noRootMarkdownClutter = createFileRule(
'open-pencil/no-root-markdown-clutter',
(sourceRel) => {
if (sourceRel.includes('/')) return null
if (!sourceRel.endsWith('.md')) return null
if (ROOT_MARKDOWN_ALLOWLIST.has(sourceRel)) return null
return 'Do not add ad hoc root Markdown files. Put durable docs under packages/docs/** or update the root allowlist deliberately.'
}
)
const noPrototypeOrGeneratedImports = createImportRule(
'open-pencil/no-prototype-or-generated-imports',
@ -353,7 +386,10 @@ const noPackageInternalsInApp = createImportRule(
'open-pencil/no-package-internals-in-app',
(sourceRel, specifier, resolved) => {
if (!sourceRel.startsWith('src/')) return null
if (specifier in PACKAGE_ALIASES || Object.keys(PACKAGE_ALIASES).some((alias) => specifier.startsWith(alias))) {
if (
specifier in PACKAGE_ALIASES ||
Object.keys(PACKAGE_ALIASES).some((alias) => specifier.startsWith(alias))
) {
return 'App code must use package public exports such as @open-pencil/core or @open-pencil/vue, not package-local aliases.'
}
if (resolved?.startsWith('packages/')) {
@ -415,7 +451,8 @@ const noViewsImportedOutsideEntry = createImportRule(
'open-pencil/no-views-imported-outside-entry',
(sourceRel, _specifier, resolved) => {
if (!resolved?.startsWith('src/views/')) return null
if (sourceRel === 'src/App.vue' || sourceRel === 'src/main.ts' || sourceRel === 'src/router.ts') return null
if (sourceRel === 'src/App.vue' || sourceRel === 'src/main.ts' || sourceRel === 'src/router.ts')
return null
return 'Views are top-level composition entrypoints and must not be imported by app services or reusable components.'
}
)
@ -441,6 +478,35 @@ const noPropertyPanelInternalsOutsidePanel = createImportRule(
}
)
const SHORTCUT_LABEL_PATTERN = /(?:Shift|Ctrl|Alt|Option|Cmd|Command||||)\s*[+)\w]/u
const noShortcutTextInLabels = createTextRule(
'open-pencil/no-shortcut-text-in-labels',
(sourceRel, content) => {
if (
sourceRel !== 'packages/vue/src/i18n/messages.ts' &&
!sourceRel.startsWith('packages/vue/src/locales/')
) {
return []
}
const diagnostics: Array<{ message: string; line?: number; column?: number }> = []
for (const match of content.matchAll(
/['"]([^'"]*(?:Shift|Ctrl|Alt|Option|Cmd|Command||||)[^'"]*)['"]/gu
)) {
if (!SHORTCUT_LABEL_PATTERN.test(match[1])) continue
const before = content.slice(0, match.index)
const lines = before.split('\n')
diagnostics.push({
message: 'Keep shortcuts in command metadata, not translated labels.',
line: lines.length,
column: lines.at(-1)?.length ?? 0
})
}
return diagnostics
}
)
const noUiImportsInCore = createImportRule(
'open-pencil/no-ui-imports-in-core',
(sourceRel, specifier) => {
@ -479,6 +545,7 @@ export const openPencilArchitecturePlugin = {
noNonUiImportsInSharedUi,
noAppImportsInSharedUi,
noPropertyPanelInternalsOutsidePanel,
noShortcutTextInLabels,
noUiImportsInCore
]
}

View file

@ -1,10 +1,10 @@
import { describe, test, expect } from 'bun:test'
import { computeVectorBounds } from '@open-pencil/core'
import { computeAccurateBounds } from '@open-pencil/core'
describe('computeVectorBounds', () => {
describe('computeAccurateBounds', () => {
test('empty network', () => {
expect(computeVectorBounds({ vertices: [], segments: [], regions: [] })).toEqual({
expect(computeAccurateBounds({ vertices: [], segments: [], regions: [] })).toEqual({
x: 0,
y: 0,
width: 0,
@ -13,7 +13,7 @@ describe('computeVectorBounds', () => {
})
test('single vertex', () => {
const bounds = computeVectorBounds({
const bounds = computeAccurateBounds({
vertices: [{ x: 50, y: 30, handleMirroring: 'NONE' }],
segments: [],
regions: []
@ -25,7 +25,7 @@ describe('computeVectorBounds', () => {
})
test('two vertices', () => {
const bounds = computeVectorBounds({
const bounds = computeAccurateBounds({
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' },
{ x: 100, y: 50, handleMirroring: 'NONE' }
@ -37,7 +37,7 @@ describe('computeVectorBounds', () => {
})
test('bezier curve extrema extend bounds', () => {
const bounds = computeVectorBounds({
const bounds = computeAccurateBounds({
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' },
{ x: 100, y: 0, handleMirroring: 'NONE' }