refactor(vue): centralize canvas menu groups
This commit is contained in:
parent
5634e0ecb4
commit
29d7ae4817
|
|
@ -172,7 +172,7 @@
|
|||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/**/*.ts"],
|
||||
"files": ["src/**/*.ts", "packages/*/src/**/*.ts"],
|
||||
"rules": {
|
||||
"open-pencil/no-useless-pass-through-wrappers": "error"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -223,8 +223,7 @@ export {
|
|||
geometryBlobToPath,
|
||||
decodeVectorNetworkBlob,
|
||||
encodeVectorNetworkBlob,
|
||||
buildStyleOverrideTable,
|
||||
computeVectorBounds
|
||||
buildStyleOverrideTable
|
||||
} from './vector'
|
||||
export {
|
||||
evalCubic,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
|
|
|
|||
98
packages/vue/src/editor/menu-model/canvas.ts
Normal file
98
packages/vue/src/editor/menu-model/canvas.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@
|
|||
"detachVariable": "Отвязать переменную",
|
||||
"applyVariable": "Применить переменную",
|
||||
"noVariablesFound": "Переменные не найдены",
|
||||
"addAutoLayout": "Добавить автораскладку (Shift+A)",
|
||||
"addAutoLayout": "Добавить автораскладку",
|
||||
"removeAutoLayout": "Убрать автораскладку",
|
||||
"alignLeft": "Выровнять по левому краю",
|
||||
"alignCenterHorizontally": "Выровнять по центру по горизонтали",
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@
|
|||
"detachVariable": "解除变量绑定",
|
||||
"applyVariable": "应用变量",
|
||||
"noVariablesFound": "未找到变量",
|
||||
"addAutoLayout": "添加自动布局 (Shift+A)",
|
||||
"addAutoLayout": "添加自动布局",
|
||||
"removeAutoLayout": "移除自动布局",
|
||||
"alignLeft": "左对齐",
|
||||
"alignCenterHorizontally": "水平居中",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
Loading…
Reference in a new issue