refactor(vue): centralize canvas menu groups
This commit is contained in:
parent
5634e0ecb4
commit
29d7ae4817
|
|
@ -172,7 +172,7 @@
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["src/**/*.ts"],
|
"files": ["src/**/*.ts", "packages/*/src/**/*.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"open-pencil/no-useless-pass-through-wrappers": "error"
|
"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 * as React from './mini-react'
|
||||||
import { renderTree, type RenderResult } from './renderer'
|
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.
|
* Build a component function from a JSX string using sucrase.
|
||||||
|
|
@ -78,13 +78,4 @@ export async function renderJSX(
|
||||||
return [await renderTree(graph, tree, options)]
|
return [await renderTree(graph, tree, options)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { renderTree as renderTreeNode }
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { EditorContext } from '#core/editor/types'
|
import type { EditorContext } from '#core/editor/types'
|
||||||
import type { SceneNode, VectorNetwork, VectorRegion, VectorSegment } from '#core/scene-graph'
|
import type { SceneNode, VectorNetwork, VectorRegion, VectorSegment } from '#core/scene-graph'
|
||||||
import type { Vector } from '#core/types'
|
import type { Vector } from '#core/types'
|
||||||
import { computeVectorBounds } from '#core/vector'
|
import { computeAccurateBounds } from '#core/vector'
|
||||||
|
|
||||||
export interface PenDragOptions {
|
export interface PenDragOptions {
|
||||||
keepOpposite?: boolean
|
keepOpposite?: boolean
|
||||||
|
|
@ -175,7 +175,7 @@ export function createPenActions(ctx: EditorContext, createShape: CreateShape) {
|
||||||
regions
|
regions
|
||||||
}
|
}
|
||||||
|
|
||||||
const bounds = computeVectorBounds(network)
|
const bounds = computeAccurateBounds(network)
|
||||||
|
|
||||||
const normalizedVertices = network.vertices.map((v) => ({
|
const normalizedVertices = network.vertices.map((v) => ({
|
||||||
...v,
|
...v,
|
||||||
|
|
|
||||||
|
|
@ -68,15 +68,7 @@ export async function fetchIcons(names: string[], size = 24): Promise<Map<string
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchIcons(
|
export { searchIconify as searchIcons }
|
||||||
query: string,
|
|
||||||
options?: {
|
|
||||||
limit?: number
|
|
||||||
prefix?: string
|
|
||||||
}
|
|
||||||
): Promise<IconSearchResult> {
|
|
||||||
return searchIconify(query, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchIconsBatch(
|
export async function searchIconsBatch(
|
||||||
queries: string[],
|
queries: string[],
|
||||||
|
|
@ -88,7 +80,7 @@ export async function searchIconsBatch(
|
||||||
const results = new Map<string, IconSearchResult>()
|
const results = new Map<string, IconSearchResult>()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
queries.map(async (query) => {
|
queries.map(async (query) => {
|
||||||
const result = await searchIcons(query, options)
|
const result = await searchIconify(query, options)
|
||||||
results.set(query, result)
|
results.set(query, result)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -223,8 +223,7 @@ export {
|
||||||
geometryBlobToPath,
|
geometryBlobToPath,
|
||||||
decodeVectorNetworkBlob,
|
decodeVectorNetworkBlob,
|
||||||
encodeVectorNetworkBlob,
|
encodeVectorNetworkBlob,
|
||||||
buildStyleOverrideTable,
|
buildStyleOverrideTable
|
||||||
computeVectorBounds
|
|
||||||
} from './vector'
|
} from './vector'
|
||||||
export {
|
export {
|
||||||
evalCubic,
|
evalCubic,
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,6 @@ import type {
|
||||||
VectorVertex,
|
VectorVertex,
|
||||||
WindingRule
|
WindingRule
|
||||||
} from '#core/scene-graph'
|
} from '#core/scene-graph'
|
||||||
import type { Rect } from '#core/types'
|
|
||||||
|
|
||||||
import { computeAccurateBounds } from './bezier'
|
|
||||||
|
|
||||||
export { vectorNetworkToCenterlinePath, fitCircleArc, isClosedThinCrescent } from './centerline'
|
export { vectorNetworkToCenterlinePath, fitCircleArc, isClosedThinCrescent } from './centerline'
|
||||||
|
|
||||||
// --- vectorNetworkBlob binary format ---
|
// --- vectorNetworkBlob binary format ---
|
||||||
|
|
@ -280,10 +276,6 @@ function addLoopToPath(
|
||||||
path.close()
|
path.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeVectorBounds(network: VectorNetwork): Rect {
|
|
||||||
return computeAccurateBounds(network)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CMD_CLOSE = 0
|
const CMD_CLOSE = 0
|
||||||
const CMD_MOVE_TO = 1
|
const CMD_MOVE_TO = 1
|
||||||
const CMD_LINE_TO = 2
|
const CMD_LINE_TO = 2
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1 @@
|
||||||
import type { Editor } from '@open-pencil/core/editor'
|
export { createTransformInputActions as createCanvasTransformInput } from '#vue/canvas/transform-input/actions'
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,9 @@ import {
|
||||||
OBJECT_MENU_COMMAND_GROUPS,
|
OBJECT_MENU_COMMAND_GROUPS,
|
||||||
VIEW_MENU_COMMANDS
|
VIEW_MENU_COMMANDS
|
||||||
} from '#vue/editor/menu-model/command-groups'
|
} from '#vue/editor/menu-model/command-groups'
|
||||||
import type { MenuActionNode, MenuEntry, MenuSeparatorNode } from '#vue/editor/menu-model/types'
|
import type { MenuEntry } from '#vue/editor/menu-model/types'
|
||||||
import type { useSelectionState } from '#vue/editor/selection-state/use'
|
|
||||||
|
|
||||||
type CommandMenuItem = ReturnType<typeof useEditorCommands>['menuItem']
|
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(
|
function commandGroupEntries(
|
||||||
commandMenuItem: CommandMenuItem,
|
commandMenuItem: CommandMenuItem,
|
||||||
|
|
@ -46,47 +32,3 @@ export function buildViewMenu(commandMenuItem: CommandMenuItem): MenuEntry[] {
|
||||||
export function buildObjectMenu(commandMenuItem: CommandMenuItem): MenuEntry[] {
|
export function buildObjectMenu(commandMenuItem: CommandMenuItem): MenuEntry[] {
|
||||||
return commandGroupEntries(commandMenuItem, OBJECT_MENU_COMMAND_GROUPS)
|
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 { useEditorCommands } from '#vue/editor/commands/use'
|
||||||
import { useEditor } from '#vue/editor/context'
|
import { useEditor } from '#vue/editor/context'
|
||||||
import {
|
import { buildEditMenu, buildObjectMenu, buildViewMenu } from '#vue/editor/menu-model/builders'
|
||||||
buildCanvasMenu,
|
import { buildCanvasContextMenu } from '#vue/editor/menu-model/canvas'
|
||||||
buildEditMenu,
|
|
||||||
buildObjectMenu,
|
|
||||||
buildViewMenu
|
|
||||||
} from '#vue/editor/menu-model/builders'
|
|
||||||
import { useSelectionState } from '#vue/editor/selection-state/use'
|
import { useSelectionState } from '#vue/editor/selection-state/use'
|
||||||
import { menuMessages } from '#vue/i18n'
|
import { menuMessages } from '#vue/i18n'
|
||||||
|
|
||||||
|
|
@ -46,7 +42,7 @@ export function useMenuModel() {
|
||||||
])
|
])
|
||||||
|
|
||||||
const canvasMenu = computed<MenuEntry[]>(() =>
|
const canvasMenu = computed<MenuEntry[]>(() =>
|
||||||
buildCanvasMenu({
|
buildCanvasContextMenu({
|
||||||
commandMenuItem,
|
commandMenuItem,
|
||||||
otherPages: otherPages.value,
|
otherPages: otherPages.value,
|
||||||
moveSelectionToPage,
|
moveSelectionToPage,
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"detachVariable": "Variable lösen",
|
"detachVariable": "Variable lösen",
|
||||||
"applyVariable": "Variable anwenden",
|
"applyVariable": "Variable anwenden",
|
||||||
"noVariablesFound": "Keine Variablen gefunden",
|
"noVariablesFound": "Keine Variablen gefunden",
|
||||||
"addAutoLayout": "Auto-Layout hinzufügen (Shift+A)",
|
"addAutoLayout": "Auto-Layout hinzufügen",
|
||||||
"removeAutoLayout": "Auto-Layout entfernen",
|
"removeAutoLayout": "Auto-Layout entfernen",
|
||||||
"mixed": "Gemischt",
|
"mixed": "Gemischt",
|
||||||
"layersCount": "{count} Ebenen",
|
"layersCount": "{count} Ebenen",
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"detachVariable": "Desvincular variable",
|
"detachVariable": "Desvincular variable",
|
||||||
"applyVariable": "Aplicar variable",
|
"applyVariable": "Aplicar variable",
|
||||||
"noVariablesFound": "No se encontraron variables",
|
"noVariablesFound": "No se encontraron variables",
|
||||||
"addAutoLayout": "Añadir auto-layout (Shift+A)",
|
"addAutoLayout": "Añadir auto-layout",
|
||||||
"removeAutoLayout": "Quitar auto-layout",
|
"removeAutoLayout": "Quitar auto-layout",
|
||||||
"mixed": "Mixto",
|
"mixed": "Mixto",
|
||||||
"layersCount": "{count} capas",
|
"layersCount": "{count} capas",
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"detachVariable": "Détacher la variable",
|
"detachVariable": "Détacher la variable",
|
||||||
"applyVariable": "Appliquer la variable",
|
"applyVariable": "Appliquer la variable",
|
||||||
"noVariablesFound": "Aucune variable trouvée",
|
"noVariablesFound": "Aucune variable trouvée",
|
||||||
"addAutoLayout": "Ajouter un auto-layout (Shift+A)",
|
"addAutoLayout": "Ajouter un auto-layout",
|
||||||
"removeAutoLayout": "Retirer l'auto-layout",
|
"removeAutoLayout": "Retirer l'auto-layout",
|
||||||
"mixed": "Mixte",
|
"mixed": "Mixte",
|
||||||
"layersCount": "{count} calques",
|
"layersCount": "{count} calques",
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"detachVariable": "Scollega variabile",
|
"detachVariable": "Scollega variabile",
|
||||||
"applyVariable": "Applica variabile",
|
"applyVariable": "Applica variabile",
|
||||||
"noVariablesFound": "Nessuna variabile trovata",
|
"noVariablesFound": "Nessuna variabile trovata",
|
||||||
"addAutoLayout": "Aggiungi auto-layout (Shift+A)",
|
"addAutoLayout": "Aggiungi auto-layout",
|
||||||
"removeAutoLayout": "Rimuovi auto-layout",
|
"removeAutoLayout": "Rimuovi auto-layout",
|
||||||
"mixed": "Misto",
|
"mixed": "Misto",
|
||||||
"layersCount": "{count} livelli",
|
"layersCount": "{count} livelli",
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"detachVariable": "Odłącz zmienną",
|
"detachVariable": "Odłącz zmienną",
|
||||||
"applyVariable": "Zastosuj zmienną",
|
"applyVariable": "Zastosuj zmienną",
|
||||||
"noVariablesFound": "Nie znaleziono zmiennych",
|
"noVariablesFound": "Nie znaleziono zmiennych",
|
||||||
"addAutoLayout": "Dodaj auto-layout (Shift+A)",
|
"addAutoLayout": "Dodaj auto-layout",
|
||||||
"removeAutoLayout": "Usuń auto-layout",
|
"removeAutoLayout": "Usuń auto-layout",
|
||||||
"mixed": "Mieszane",
|
"mixed": "Mieszane",
|
||||||
"layersCount": "{count} warstw",
|
"layersCount": "{count} warstw",
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"detachVariable": "Отвязать переменную",
|
"detachVariable": "Отвязать переменную",
|
||||||
"applyVariable": "Применить переменную",
|
"applyVariable": "Применить переменную",
|
||||||
"noVariablesFound": "Переменные не найдены",
|
"noVariablesFound": "Переменные не найдены",
|
||||||
"addAutoLayout": "Добавить автораскладку (Shift+A)",
|
"addAutoLayout": "Добавить автораскладку",
|
||||||
"removeAutoLayout": "Убрать автораскладку",
|
"removeAutoLayout": "Убрать автораскладку",
|
||||||
"alignLeft": "Выровнять по левому краю",
|
"alignLeft": "Выровнять по левому краю",
|
||||||
"alignCenterHorizontally": "Выровнять по центру по горизонтали",
|
"alignCenterHorizontally": "Выровнять по центру по горизонтали",
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"detachVariable": "解除变量绑定",
|
"detachVariable": "解除变量绑定",
|
||||||
"applyVariable": "应用变量",
|
"applyVariable": "应用变量",
|
||||||
"noVariablesFound": "未找到变量",
|
"noVariablesFound": "未找到变量",
|
||||||
"addAutoLayout": "添加自动布局 (Shift+A)",
|
"addAutoLayout": "添加自动布局",
|
||||||
"removeAutoLayout": "移除自动布局",
|
"removeAutoLayout": "移除自动布局",
|
||||||
"alignLeft": "左对齐",
|
"alignLeft": "左对齐",
|
||||||
"alignCenterHorizontally": "水平居中",
|
"alignCenterHorizontally": "水平居中",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ type Rule = { name: string; check: (root: TreeEntry) => RuleResult }
|
||||||
|
|
||||||
type FileRuleCheck = (sourceRel: string) => string | null
|
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([
|
const FILE_PREFIX_GROUP_ALLOWLIST = new Set([
|
||||||
'packages/core/src/lint/rules::no',
|
'packages/core/src/lint/rules::no',
|
||||||
'tests/engine::visual'
|
'tests/engine::visual'
|
||||||
|
|
@ -109,6 +114,26 @@ function resolveImport(sourceRel: string, specifier: string): string | null {
|
||||||
return 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 {
|
function createFileRule(name: string, checkFile: FileRuleCheck): Rule {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
|
@ -185,7 +210,9 @@ const preferDomainFoldersOverFilenamePrefixes: Rule = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const strictTestFilePlacement = createFileRule('open-pencil/strict-test-file-placement', (sourceRel) => {
|
const strictTestFilePlacement = createFileRule(
|
||||||
|
'open-pencil/strict-test-file-placement',
|
||||||
|
(sourceRel) => {
|
||||||
if (!sourceRel.startsWith('tests/')) return null
|
if (!sourceRel.startsWith('tests/')) return null
|
||||||
if (!TEXT_EXTENSIONS.has(path.extname(sourceRel))) return null
|
if (!TEXT_EXTENSIONS.has(path.extname(sourceRel))) return null
|
||||||
const name = path.basename(sourceRel)
|
const name = path.basename(sourceRel)
|
||||||
|
|
@ -197,7 +224,9 @@ const strictTestFilePlacement = createFileRule('open-pencil/strict-test-file-pla
|
||||||
return 'E2E tests must live under tests/e2e/** and use *.spec.ts.'
|
return 'E2E tests must live under tests/e2e/** and use *.spec.ts.'
|
||||||
}
|
}
|
||||||
if (sourceRel.startsWith('tests/figma/')) {
|
if (sourceRel.startsWith('tests/figma/')) {
|
||||||
return sourceRel.endsWith('.spec.ts') ? null : 'Figma Playwright tests must live under tests/figma/** and use *.spec.ts.'
|
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.startsWith('tests/engine/')) {
|
||||||
if (sourceRel.endsWith('.test.ts')) return null
|
if (sourceRel.endsWith('.test.ts')) return null
|
||||||
|
|
@ -207,7 +236,8 @@ const strictTestFilePlacement = createFileRule('open-pencil/strict-test-file-pla
|
||||||
}
|
}
|
||||||
if (sourceRel.startsWith('tests/helpers/')) return null
|
if (sourceRel.startsWith('tests/helpers/')) return null
|
||||||
return 'Tests must live under tests/e2e/** (*.spec.ts), tests/engine/** (*.test.ts), or tests/helpers/**.'
|
return 'Tests must live under tests/e2e/** (*.spec.ts), tests/engine/** (*.test.ts), or tests/helpers/**.'
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const ENGINE_TEST_DOMAIN_REDIRECTS: Array<{
|
const ENGINE_TEST_DOMAIN_REDIRECTS: Array<{
|
||||||
from: string
|
from: string
|
||||||
|
|
@ -295,12 +325,15 @@ const noE2EImportsInEngineTests = createImportRule(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const noRootMarkdownClutter = createFileRule('open-pencil/no-root-markdown-clutter', (sourceRel) => {
|
const noRootMarkdownClutter = createFileRule(
|
||||||
|
'open-pencil/no-root-markdown-clutter',
|
||||||
|
(sourceRel) => {
|
||||||
if (sourceRel.includes('/')) return null
|
if (sourceRel.includes('/')) return null
|
||||||
if (!sourceRel.endsWith('.md')) return null
|
if (!sourceRel.endsWith('.md')) return null
|
||||||
if (ROOT_MARKDOWN_ALLOWLIST.has(sourceRel)) 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.'
|
return 'Do not add ad hoc root Markdown files. Put durable docs under packages/docs/** or update the root allowlist deliberately.'
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const noPrototypeOrGeneratedImports = createImportRule(
|
const noPrototypeOrGeneratedImports = createImportRule(
|
||||||
'open-pencil/no-prototype-or-generated-imports',
|
'open-pencil/no-prototype-or-generated-imports',
|
||||||
|
|
@ -353,7 +386,10 @@ const noPackageInternalsInApp = createImportRule(
|
||||||
'open-pencil/no-package-internals-in-app',
|
'open-pencil/no-package-internals-in-app',
|
||||||
(sourceRel, specifier, resolved) => {
|
(sourceRel, specifier, resolved) => {
|
||||||
if (!sourceRel.startsWith('src/')) return null
|
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.'
|
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/')) {
|
if (resolved?.startsWith('packages/')) {
|
||||||
|
|
@ -415,7 +451,8 @@ const noViewsImportedOutsideEntry = createImportRule(
|
||||||
'open-pencil/no-views-imported-outside-entry',
|
'open-pencil/no-views-imported-outside-entry',
|
||||||
(sourceRel, _specifier, resolved) => {
|
(sourceRel, _specifier, resolved) => {
|
||||||
if (!resolved?.startsWith('src/views/')) return null
|
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.'
|
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(
|
const noUiImportsInCore = createImportRule(
|
||||||
'open-pencil/no-ui-imports-in-core',
|
'open-pencil/no-ui-imports-in-core',
|
||||||
(sourceRel, specifier) => {
|
(sourceRel, specifier) => {
|
||||||
|
|
@ -479,6 +545,7 @@ export const openPencilArchitecturePlugin = {
|
||||||
noNonUiImportsInSharedUi,
|
noNonUiImportsInSharedUi,
|
||||||
noAppImportsInSharedUi,
|
noAppImportsInSharedUi,
|
||||||
noPropertyPanelInternalsOutsidePanel,
|
noPropertyPanelInternalsOutsidePanel,
|
||||||
|
noShortcutTextInLabels,
|
||||||
noUiImportsInCore
|
noUiImportsInCore
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { describe, test, expect } from 'bun:test'
|
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', () => {
|
test('empty network', () => {
|
||||||
expect(computeVectorBounds({ vertices: [], segments: [], regions: [] })).toEqual({
|
expect(computeAccurateBounds({ vertices: [], segments: [], regions: [] })).toEqual({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 0,
|
width: 0,
|
||||||
|
|
@ -13,7 +13,7 @@ describe('computeVectorBounds', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('single vertex', () => {
|
test('single vertex', () => {
|
||||||
const bounds = computeVectorBounds({
|
const bounds = computeAccurateBounds({
|
||||||
vertices: [{ x: 50, y: 30, handleMirroring: 'NONE' }],
|
vertices: [{ x: 50, y: 30, handleMirroring: 'NONE' }],
|
||||||
segments: [],
|
segments: [],
|
||||||
regions: []
|
regions: []
|
||||||
|
|
@ -25,7 +25,7 @@ describe('computeVectorBounds', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('two vertices', () => {
|
test('two vertices', () => {
|
||||||
const bounds = computeVectorBounds({
|
const bounds = computeAccurateBounds({
|
||||||
vertices: [
|
vertices: [
|
||||||
{ x: 0, y: 0, handleMirroring: 'NONE' },
|
{ x: 0, y: 0, handleMirroring: 'NONE' },
|
||||||
{ x: 100, y: 50, handleMirroring: 'NONE' }
|
{ x: 100, y: 50, handleMirroring: 'NONE' }
|
||||||
|
|
@ -37,7 +37,7 @@ describe('computeVectorBounds', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('bezier curve extrema extend bounds', () => {
|
test('bezier curve extrema extend bounds', () => {
|
||||||
const bounds = computeVectorBounds({
|
const bounds = computeAccurateBounds({
|
||||||
vertices: [
|
vertices: [
|
||||||
{ x: 0, y: 0, handleMirroring: 'NONE' },
|
{ x: 0, y: 0, handleMirroring: 'NONE' },
|
||||||
{ x: 100, y: 0, handleMirroring: 'NONE' }
|
{ x: 100, y: 0, handleMirroring: 'NONE' }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue