From 29d7ae4817b1469c282dd4eac418544ccce09821 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 17 May 2026 14:09:52 +0300 Subject: [PATCH] refactor(vue): centralize canvas menu groups --- oxlint.json | 2 +- packages/core/src/design-jsx/render.ts | 13 +- packages/core/src/editor/shapes/pen.ts | 4 +- packages/core/src/icons/index.ts | 12 +- packages/core/src/index.ts | 3 +- packages/core/src/vector/index.ts | 8 -- .../vue/src/canvas/transform-input/use.ts | 16 +-- .../vue/src/editor/menu-model/builders.ts | 60 +-------- packages/vue/src/editor/menu-model/canvas.ts | 98 ++++++++++++++ packages/vue/src/editor/menu-model/use.ts | 10 +- packages/vue/src/locales/de.json | 2 +- packages/vue/src/locales/es.json | 2 +- packages/vue/src/locales/fr.json | 2 +- packages/vue/src/locales/it.json | 2 +- packages/vue/src/locales/pl.json | 2 +- packages/vue/src/locales/ru.json | 2 +- packages/vue/src/locales/zh-CN.json | 2 +- scripts/steiger-rules.ts | 127 +++++++++++++----- tests/engine/vector/bounds.test.ts | 12 +- 19 files changed, 221 insertions(+), 158 deletions(-) create mode 100644 packages/vue/src/editor/menu-model/canvas.ts diff --git a/oxlint.json b/oxlint.json index 9cda4c5fa..d7e7c4ada 100644 --- a/oxlint.json +++ b/oxlint.json @@ -172,7 +172,7 @@ }, "overrides": [ { - "files": ["src/**/*.ts"], + "files": ["src/**/*.ts", "packages/*/src/**/*.ts"], "rules": { "open-pencil/no-useless-pass-through-wrappers": "error" } diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index 937e5f671..fbec15d63 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -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 { - return renderTree(graph, tree, options) -} +export { renderTree as renderTreeNode } diff --git a/packages/core/src/editor/shapes/pen.ts b/packages/core/src/editor/shapes/pen.ts index 8a513b1e6..7d8e7b3c8 100644 --- a/packages/core/src/editor/shapes/pen.ts +++ b/packages/core/src/editor/shapes/pen.ts @@ -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, diff --git a/packages/core/src/icons/index.ts b/packages/core/src/icons/index.ts index e7eaa621b..bc9e2286a 100644 --- a/packages/core/src/icons/index.ts +++ b/packages/core/src/icons/index.ts @@ -68,15 +68,7 @@ export async function fetchIcons(names: string[], size = 24): Promise { - 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() await Promise.all( queries.map(async (query) => { - const result = await searchIcons(query, options) + const result = await searchIconify(query, options) results.set(query, result) }) ) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8c0ece59..7179a672e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -223,8 +223,7 @@ export { geometryBlobToPath, decodeVectorNetworkBlob, encodeVectorNetworkBlob, - buildStyleOverrideTable, - computeVectorBounds + buildStyleOverrideTable } from './vector' export { evalCubic, diff --git a/packages/core/src/vector/index.ts b/packages/core/src/vector/index.ts index 6097a9467..bcf096b0d 100644 --- a/packages/core/src/vector/index.ts +++ b/packages/core/src/vector/index.ts @@ -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 diff --git a/packages/vue/src/canvas/transform-input/use.ts b/packages/vue/src/canvas/transform-input/use.ts index c630a5fd7..d824683e4 100644 --- a/packages/vue/src/canvas/transform-input/use.ts +++ b/packages/vue/src/canvas/transform-input/use.ts @@ -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' diff --git a/packages/vue/src/editor/menu-model/builders.ts b/packages/vue/src/editor/menu-model/builders.ts index f94c9680e..e4afac116 100644 --- a/packages/vue/src/editor/menu-model/builders.ts +++ b/packages/vue/src/editor/menu-model/builders.ts @@ -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['menuItem'] -type SelectionState = ReturnType - -type MenuTranslations = { - moveToPage: string -} - -type CanvasMenuOptions = { - commandMenuItem: CommandMenuItem - otherPages: ReturnType['otherPages']['value'] - moveSelectionToPage: ReturnType['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') - ] - : []) - ] -} diff --git a/packages/vue/src/editor/menu-model/canvas.ts b/packages/vue/src/editor/menu-model/canvas.ts new file mode 100644 index 000000000..17aa6cf49 --- /dev/null +++ b/packages/vue/src/editor/menu-model/canvas.ts @@ -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['menuItem'] +type SelectionState = ReturnType + +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['otherPages']['value'] + moveSelectionToPage: ReturnType['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 +} diff --git a/packages/vue/src/editor/menu-model/use.ts b/packages/vue/src/editor/menu-model/use.ts index fce5e8777..72c957099 100644 --- a/packages/vue/src/editor/menu-model/use.ts +++ b/packages/vue/src/editor/menu-model/use.ts @@ -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(() => - buildCanvasMenu({ + buildCanvasContextMenu({ commandMenuItem, otherPages: otherPages.value, moveSelectionToPage, diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json index b58adb64f..98cf07893 100644 --- a/packages/vue/src/locales/de.json +++ b/packages/vue/src/locales/de.json @@ -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", diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json index 5ac568dc4..5da9c7c3f 100644 --- a/packages/vue/src/locales/es.json +++ b/packages/vue/src/locales/es.json @@ -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", diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json index a3117e825..944343741 100644 --- a/packages/vue/src/locales/fr.json +++ b/packages/vue/src/locales/fr.json @@ -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", diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json index d408b6f5b..bfadc3168 100644 --- a/packages/vue/src/locales/it.json +++ b/packages/vue/src/locales/it.json @@ -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", diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json index d6fbcb3c4..c2716f6c9 100644 --- a/packages/vue/src/locales/pl.json +++ b/packages/vue/src/locales/pl.json @@ -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", diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index 51340052a..1cbbb5978 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -115,7 +115,7 @@ "detachVariable": "Отвязать переменную", "applyVariable": "Применить переменную", "noVariablesFound": "Переменные не найдены", - "addAutoLayout": "Добавить автораскладку (Shift+A)", + "addAutoLayout": "Добавить автораскладку", "removeAutoLayout": "Убрать автораскладку", "alignLeft": "Выровнять по левому краю", "alignCenterHorizontally": "Выровнять по центру по горизонтали", diff --git a/packages/vue/src/locales/zh-CN.json b/packages/vue/src/locales/zh-CN.json index d08202daa..4c8368c38 100644 --- a/packages/vue/src/locales/zh-CN.json +++ b/packages/vue/src/locales/zh-CN.json @@ -115,7 +115,7 @@ "detachVariable": "解除变量绑定", "applyVariable": "应用变量", "noVariablesFound": "未找到变量", - "addAutoLayout": "添加自动布局 (Shift+A)", + "addAutoLayout": "添加自动布局", "removeAutoLayout": "移除自动布局", "alignLeft": "左对齐", "alignCenterHorizontally": "水平居中", diff --git a/scripts/steiger-rules.ts b/scripts/steiger-rules.ts index e6bf49a0a..8cdf21eeb 100644 --- a/scripts/steiger-rules.ts +++ b/scripts/steiger-rules.ts @@ -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 ] } diff --git a/tests/engine/vector/bounds.test.ts b/tests/engine/vector/bounds.test.ts index 525817d9a..d97aa599a 100644 --- a/tests/engine/vector/bounds.test.ts +++ b/tests/engine/vector/bounds.test.ts @@ -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' }