This commit is contained in:
2026-04-08 21:26:18 +08:00
commit 8fdc7ac0c3
401 changed files with 53093 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
{
"name": "@cslab-dcs/bridge",
"type": "module",
"version": "1.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./tauri": {
"types": "./src/tauri.ts",
"import": "./src/tauri.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.5",
"@tauri-apps/plugin-os": "^2.3.2"
},
"devDependencies": {
"electron": "^33.2.1",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,70 @@
import type {
ConfirmOptions,
FileDialogOptions,
IDialogService,
IFileService,
IPlatformBridge,
ISystemService,
MessageOptions,
PlatformType,
SaveDialogOptions,
} from '../types'
// Electron IPC 接口定义(需要在 electron preload 中实现)
interface IElectronAPI {
ipcRenderer: {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
send: (channel: string, ...args: unknown[]) => void
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void
removeListener: (channel: string, listener: (...args: unknown[]) => void) => void
}
}
declare global {
interface Window {
electron: IElectronAPI
}
}
export class ElectronBridge implements IPlatformBridge {
readonly platform: PlatformType = 'electron'
file: IFileService = {
async read(path: string): Promise<string> {
return (await window.electron.ipcRenderer.invoke('file:read', path)) as string
},
async write(path: string, content: string): Promise<void> {
await window.electron.ipcRenderer.invoke('file:write', path, content)
},
async exists(path: string): Promise<boolean> {
return (await window.electron.ipcRenderer.invoke('file:exists', path)) as boolean
},
async openDialog(options?: FileDialogOptions): Promise<string | string[] | null> {
return (await window.electron.ipcRenderer.invoke('dialog:open', options)) as string | string[] | null
},
async saveDialog(options?: SaveDialogOptions): Promise<string | null> {
return (await window.electron.ipcRenderer.invoke('dialog:save', options)) as string | null
},
}
dialog: IDialogService = {
async message(options: MessageOptions): Promise<void> {
await window.electron.ipcRenderer.invoke('dialog:message', options)
},
async confirm(options: ConfirmOptions): Promise<boolean> {
return (await window.electron.ipcRenderer.invoke('dialog:confirm', options)) as boolean
},
}
system: ISystemService = {
async getAppVersion(): Promise<string> {
return (await window.electron.ipcRenderer.invoke('app:version')) as string
},
async getPlatformInfo() {
return (await window.electron.ipcRenderer.invoke('app:info')) as any
},
async openExternal(url: string): Promise<void> {
await window.electron.ipcRenderer.invoke('shell:open', url)
},
}
}

View File

@@ -0,0 +1,86 @@
import type {
ConfirmOptions,
FileDialogOptions,
IDialogService,
IFileService,
IPlatformBridge,
ISystemService,
MessageOptions,
PlatformType,
SaveDialogOptions,
} from '../types'
import { getTauriVersion, getVersion } from '@tauri-apps/api/app'
import { ask, message, open, save } from '@tauri-apps/plugin-dialog'
import { exists, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { openUrl } from '@tauri-apps/plugin-opener'
import { arch, version as osVersion, platform } from '@tauri-apps/plugin-os'
export class TauriBridge implements IPlatformBridge {
readonly platform: PlatformType = 'tauri'
file: IFileService = {
async read(path: string): Promise<string> {
return await readTextFile(path)
},
async write(path: string, content: string): Promise<void> {
await writeTextFile(path, content)
},
async exists(path: string): Promise<boolean> {
return await exists(path)
},
async openDialog(options?: FileDialogOptions): Promise<string | string[] | null> {
const selected = await open({
multiple: options?.multiple ?? false,
directory: options?.directory ?? false,
filters: options?.filters,
defaultPath: options?.defaultPath,
})
if (selected === null)
return null
// 这里的类型转换取决于 multiple 选项
return selected as string | string[]
},
async saveDialog(options?: SaveDialogOptions): Promise<string | null> {
return await save({
defaultPath: options?.defaultPath,
filters: options?.filters,
})
},
}
dialog: IDialogService = {
async message(options: MessageOptions): Promise<void> {
await message(options.message, {
title: options.title,
kind: options.type === 'error' ? 'error' : options.type === 'warning' ? 'warning' : 'info',
})
},
async confirm(options: ConfirmOptions): Promise<boolean> {
return await ask(options.message, {
title: options.title,
kind: options.type === 'error' ? 'error' : options.type === 'warning' ? 'warning' : 'info',
okLabel: options.okLabel || 'Yes',
cancelLabel: options.cancelLabel || 'No',
})
},
}
system: ISystemService = {
async getAppVersion(): Promise<string> {
return await getVersion()
},
async getPlatformInfo() {
return {
name: 'tauri',
version: await getTauriVersion(),
os: await platform(),
osVersion: await osVersion(),
arch: await arch(),
}
},
async openExternal(url: string): Promise<void> {
await openUrl(url)
},
}
}

View File

@@ -0,0 +1,96 @@
import type {
ConfirmOptions,
FileDialogOptions,
IDialogService,
IFileService,
IPlatformBridge,
ISystemService,
MessageOptions,
PlatformType,
SaveDialogOptions,
} from '../types'
const MAC_OS_VERSION_RE = /Mac OS X ([\d_]+)/
const WINDOWS_VERSION_RE = /Windows NT ([\d.]+)/
const UNDERSCORE_RE = /_/g
function parseOsVersion(userAgent: string): string | undefined {
const macMatch = userAgent.match(MAC_OS_VERSION_RE)
if (macMatch)
return macMatch[1].replace(UNDERSCORE_RE, '.')
const winMatch = userAgent.match(WINDOWS_VERSION_RE)
if (winMatch)
return winMatch[1]
return undefined
}
export class WebBridge implements IPlatformBridge {
readonly platform: PlatformType = 'web'
file: IFileService = {
async read(_path: string): Promise<string> {
console.warn('Web environment does not support file system access directly.')
return ''
},
async write(_path: string, _content: string): Promise<void> {
console.warn('Web environment does not support file system access directly.')
},
async exists(_path: string): Promise<boolean> {
return false
},
async openDialog(_options?: FileDialogOptions): Promise<string | string[] | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
// Web端模拟仅用于演示
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files
if (files && files.length > 0) {
// Web端通常不能获取完整路径这里只是模拟
resolve(files[0].name)
}
else {
resolve(null)
}
}
input.click()
})
},
async saveDialog(_options?: SaveDialogOptions): Promise<string | null> {
console.warn('Web environment save dialog not fully supported.')
return null
},
}
dialog: IDialogService = {
async message(options: MessageOptions): Promise<void> {
// eslint-disable-next-line no-alert
alert(`${options.title ? `${options.title}\n` : ''}${options.message}`)
},
async confirm(options: ConfirmOptions): Promise<boolean> {
// eslint-disable-next-line no-alert
return confirm(`${options.title ? `${options.title}\n` : ''}${options.message}`)
},
}
system: ISystemService = {
async getAppVersion(): Promise<string> {
return '1.0.0'
},
async getPlatformInfo() {
const osVersion = parseOsVersion(navigator.userAgent)
return {
name: 'web',
version: navigator.userAgent,
os: navigator.platform,
osVersion,
arch: 'unknown',
}
},
async openExternal(url: string): Promise<void> {
window.open(url, '_blank')
},
}
}

View File

@@ -0,0 +1,24 @@
import type { IPlatformBridge } from './types'
import { ElectronBridge } from './adapters/electron'
import { WebBridge } from './adapters/web'
export { ElectronBridge } from './adapters/electron'
export { WebBridge } from './adapters/web'
export * from './types'
export function createBridge(): IPlatformBridge {
if (typeof window !== 'undefined') {
const win = window as any
if (win.electron && win.electron.ipcRenderer) {
return new ElectronBridge()
}
// Tauri 检测逻辑 - 注意:这里不能引用 TauriBridge否则会破坏 Electron 构建
// Tauri App 必须手动初始化 Bridge 并通过 provide 注入,或者我们依赖 userAgent 判断
// 但 instantiation 必须在 app 层做。
}
return new WebBridge()
}
export const bridge = createBridge()

View File

@@ -0,0 +1 @@
export * from './adapters/tauri'

View File

@@ -0,0 +1,70 @@
/**
* 平台桥接类型定义
*/
export type PlatformType = 'web' | 'electron' | 'tauri'
export interface FileDialogOptions {
title?: string
defaultPath?: string
filters?: {
name: string
extensions: string[]
}[]
multiple?: boolean
directory?: boolean
}
export interface SaveDialogOptions {
title?: string
defaultPath?: string
filters?: {
name: string
extensions: string[]
}[]
}
export interface MessageOptions {
title?: string
message: string
type?: 'info' | 'warning' | 'error'
}
export interface ConfirmOptions extends MessageOptions {
okLabel?: string
cancelLabel?: string
}
export interface PlatformInfo {
name: PlatformType
version: string
os: string
osVersion?: string
arch: string
}
export interface IFileService {
read: (path: string) => Promise<string>
write: (path: string, content: string) => Promise<void>
exists: (path: string) => Promise<boolean>
openDialog: (options?: FileDialogOptions) => Promise<string | string[] | null>
saveDialog: (options?: SaveDialogOptions) => Promise<string | null>
}
export interface IDialogService {
message: (options: MessageOptions) => Promise<void>
confirm: (options: ConfirmOptions) => Promise<boolean>
}
export interface ISystemService {
getAppVersion: () => Promise<string>
getPlatformInfo: () => Promise<PlatformInfo>
openExternal: (url: string) => Promise<void>
}
export interface IPlatformBridge {
readonly platform: PlatformType
file: IFileService
dialog: IDialogService
system: ISystemService
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "./src",
"types": ["electron", "node"],
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

4
packages/core/.env Normal file
View File

@@ -0,0 +1,4 @@
# 默认环境变量配置(会被 .env.development 和 .env.production 覆盖)
VITE_API_BASE_URL=https://cslab.oberyun.com
VITE_BASE_URL=/dcs-web/

View File

@@ -0,0 +1,3 @@
# 开发环境配置
VITE_API_BASE_URL=http://192.168.1.110:8001
VITE_WS_DOMAIN=ws://192.168.1.110:6600

View File

@@ -0,0 +1,2 @@
# 生产环境配置
VITE_API_BASE_URL=https://cslab.oberyun.com

22
packages/core/env.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
/// <reference types="element-plus/global" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
// 更多环境变量可以在这里添加...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
interface Window {
// 可以扩展 window 类型
}

View File

@@ -0,0 +1,50 @@
{
"name": "@cslab-dcs/core",
"type": "module",
"version": "1.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./vite.shared": {
"types": "./vite.shared.d.ts",
"import": "./vite.shared.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@cslab-dcs/bridge": "workspace:*",
"@cslab-dcs/schema": "workspace:*",
"@fortawesome/fontawesome-free": "^7.1.0",
"@vueuse/core": "^12.0.0",
"dayjs": "^1.11.19",
"element-plus": "^2.9.0",
"es-toolkit": "^1.44.0",
"mitt": "^3.0.1",
"ofetch": "^1.5.1",
"pinia": "^3.0.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@unocss/preset-attributify": "^66.6.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"sass": "^1.83.0",
"typescript": "^5.9.3",
"unocss": "^66.6.0",
"unocss-preset-chinese": "^0.3.3",
"unocss-preset-ease": "^0.0.4",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"vite": "^6.0.3",
"vue-tsc": "^2.2.0"
}
}

32
packages/core/src/App.vue Normal file
View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { useBridge } from '@/bootstrap'
useBridge()
</script>
<template>
<ElConfigProvider :locale="zhCn">
<router-view />
</ElConfigProvider>
</template>
<style lang="scss">
/* 全局样式 */
html, body, #app {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: auto;
position: relative;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
}
/* 确保 Element Plus 弹窗层级正确 */
.el-overlay {
position: fixed !important;
z-index: 2000 !important;
}
</style>

View File

@@ -0,0 +1,127 @@
import type { Layer } from '@cslab-dcs/schema'
import type { CanvasDetail, CanvasSummary } from '@/request'
import { requestClient } from '@/request'
import pinia, { useAppStore, useProjectStore } from '@/stores'
// 画布服务层:对 UI 提供稳定接口,屏蔽 request 底层适配器
export interface CanvasCreatePayload {
name: string
description?: string
thumbnail?: string
width?: number
height?: number
}
export interface CanvasUpdatePayload {
id: string
name: string
description?: string
}
export interface CanvasDuplicatePayload {
id: string
name?: string
}
export interface SaveCanvasComponentsPayload {
id: string
components: Layer[]
}
export interface UpdateCanvasBaseLayerPayload {
id: string
width: number
height: number
thumbnail?: string | null
lockAspectRatio?: boolean
backgroundColor?: string
}
export type CanvasListItem = CanvasSummary
export type CanvasData = CanvasDetail
function getRequestClient() {
const { isWeb } = useAppStore(pinia)
if (!isWeb)
return requestClient.local
return requestClient.local
}
export async function listCanvasesApi(): Promise<CanvasListItem[]> {
const { projectId } = useProjectStore(pinia)
if (!projectId)
return []
return getRequestClient().canvas.list(projectId)
}
export async function getCanvasByIdApi(canvasId: string): Promise<CanvasData | null> {
return getRequestClient().canvas.getById(canvasId)
}
export async function createCanvasApi(payload: CanvasCreatePayload): Promise<CanvasListItem> {
const { projectId } = useProjectStore(pinia)
if (!projectId)
throw new Error('Project ID is required to create a canvas')
return getRequestClient().canvas.create({
projectId,
name: payload.name,
description: payload.description,
thumbnail: payload.thumbnail,
width: payload.width,
height: payload.height,
})
}
export async function updateCanvasApi(payload: CanvasUpdatePayload): Promise<CanvasListItem> {
console.log('payload', payload)
return getRequestClient().canvas.update({
canvasId: payload.id,
name: payload.name,
description: payload.description,
})
}
export async function duplicateCanvasApi(payload: CanvasDuplicatePayload): Promise<CanvasListItem> {
return getRequestClient().canvas.duplicate({
canvasId: payload.id,
name: payload.name,
})
}
export async function deleteCanvasApi(canvasId: string): Promise<void> {
return getRequestClient().canvas.remove({ canvasId })
}
// 批量更新画布排序
export async function reorderCanvasesApi(canvasIds: string[]): Promise<void> {
const { projectId } = useProjectStore(pinia)
if (!projectId)
throw new Error('Project ID is required to reorder canvases')
return getRequestClient().canvas.reorder({ projectId, canvasIds })
}
// 编辑器自动保存组件快照
export async function updateComponentsLayer(payload: SaveCanvasComponentsPayload): Promise<void> {
return getRequestClient().canvas.updateComponentsLayer({
canvasId: payload.id,
components: payload.components,
})
}
// 更新画布背景图层(底图)属性
export async function updateCanvasBaseLayer(payload: UpdateCanvasBaseLayerPayload): Promise<void> {
return getRequestClient().canvas.updateBaseLayer({
canvasId: payload.id,
width: payload.width,
height: payload.height,
thumbnail: payload.thumbnail,
lockAspectRatio: payload.lockAspectRatio,
backgroundColor: payload.backgroundColor,
})
}

View File

@@ -0,0 +1,112 @@
import { createHttpClient } from '@/request/client'
export interface DynamicProjectModuleResponse {
module_pk: string
name: string
describe: string
label: string
classify: string
}
export interface DynamicProjectModule extends DynamicProjectModuleResponse {
displayLabel: string
key: string
}
export interface DynamicProjectModuleProp {
prop_pk: string
name: string
describe: string
t_module_prop: string
classify: string
}
export interface DynamicProjectProperty extends DynamicProjectModuleProp {
displayLabel: string
key: string
defaultValue?: unknown
}
const fetcher = createHttpClient()
export async function getDynamicProjectModulesApi(baseProjectId: string): Promise<DynamicProjectModule[]> {
if (!baseProjectId) {
return []
}
const data = await fetcher<DynamicProjectModuleResponse[]>('/project/module/list/', {
method: 'GET',
headers: {
'Hidden-Error': '1',
'PROJECT': baseProjectId,
},
query: {
pro: baseProjectId,
isEnum: 1,
},
})
console.log('[dynamic-project] modules 原始响应', data)
if (!Array.isArray(data)) {
console.warn('[dynamic-project] modules 响应非数组', { type: typeof data, data })
return []
}
const result: DynamicProjectModule[] = data.map(module => ({
...module,
displayLabel: `${module.name} (${module.describe})`,
key: module.module_pk,
}))
console.log('[dynamic-project] modules 归一化结果', result)
return result
}
export async function getDynamicProjectModulePropsApi(baseProjectId: string, modulePk: string): Promise<DynamicProjectProperty[]> {
if (!baseProjectId || !modulePk) {
return []
}
const data = await fetcher<{ moduleProp: DynamicProjectModuleProp[] }>('/project/module/', {
method: 'GET',
headers: {
'Hidden-Error': '1',
'PROJECT': baseProjectId,
},
query: {
pk: modulePk,
pro: baseProjectId,
isNeedFlow: 0,
},
})
console.log(`[dynamic-project] module "${modulePk}" props 原始响应`, data)
const propList = data?.moduleProp
if (!Array.isArray(propList)) {
console.warn(`[dynamic-project] module "${modulePk}" props 响应非数组`, { type: typeof propList, data })
return []
}
// 按 name 去重API 可能对同一属性返回多条记录,只保留首条
const seen = new Set<string>()
const result: DynamicProjectProperty[] = propList
.filter(prop => prop)
.filter((prop) => {
if (seen.has(prop.name)) {
return false
}
seen.add(prop.name)
return true
})
.map(prop => ({
...prop,
displayLabel: `${prop.name} (${prop.describe})`,
key: prop.prop_pk,
defaultValue: undefined,
}))
console.log(`[dynamic-project] module "${modulePk}" props 归一化结果`, { count: result.length })
return result
}

View File

@@ -0,0 +1,4 @@
export * from './canvas'
export * from './dynamic-project'
export * from './project'
export * from './runtime'

View File

@@ -0,0 +1,15 @@
import type { ProjectSummary } from '@/request'
import { requestClient } from '@/request'
import pinia, { useAppStore } from '@/stores'
function getRequestClient() {
const { isWeb } = useAppStore(pinia)
if (!isWeb)
return requestClient.local
return requestClient.http
}
export async function getProjectListApi(): Promise<ProjectSummary[]> {
return getRequestClient().project.list()
}

View File

@@ -0,0 +1,99 @@
import { ofetch } from 'ofetch'
import { createHttpClient } from '@/request/client'
/** RPC 请求客户端(指向 chemical-chaos 运算服务,响应格式与 cslab-server 不同,不复用通用拦截器) */
const rpcFetcher = ofetch.create({
baseURL: '/chemical-chaos',
timeout: 600_000,
})
/** cslab-server 请求客户端execSequence 等走 Django 后端) */
const serverFetcher = createHttpClient()
// ── 通用 RPC 调用 ──
/** common/ 接口:返回格式为 { message, result } */
function rpcCommon<T = unknown>(clazz: string, method: string, kwargs: Record<string, unknown>) {
return rpcFetcher<T>('/v1/rpc/common/', {
method: 'POST',
body: { clazz, method, args: [], kwargs },
})
}
/** zero_rpc/ 接口:返回格式为 { status, msg, data },自动解包取 data */
async function rpcZero<T = unknown>(method: string, kwargs: Record<string, unknown>) {
const res = await rpcFetcher<{ status: number, msg: string, data: T }>('/v1/rpc/zero_rpc/', {
method: 'POST',
body: { method, kwargs },
})
return res.data
}
// ── 执行顺序接口cslab-server ──
/** 获取执行顺序(运行前的必要前置调用) */
export function getExecSequenceApi(pro: string, callowWay: string) {
return serverFetcher('/project/module/execSequence/', {
method: 'GET',
params: { pro, is_preview: 0, callow_way: callowWay },
headers: { PROJECT: pro },
})
}
// ── 任务管理接口 ──
export interface AddJobParams {
pro: string
addressee: number
callow_way: 'steady' | 'dynamic' | 'design' | 'chemicalPrinciple'
is_only_checked?: boolean
is_custom_sequence?: boolean
is_steady?: boolean
need_converge?: number
current_origin?: string
is_debug?: boolean
cal_label?: string
pk?: string[]
}
export interface AddJobResult {
message: string
result: {
job: { id: string }
msg?: string
}
}
/** 添加运行任务 */
export function addJobApi(params: AddJobParams) {
return rpcCommon<AddJobResult>(
'agent.rpc_client.run.run',
'Run.add_job',
params as unknown as Record<string, unknown>,
)
}
/** 暂停任务 */
export function pauseJobApi(taskId: string, addressee: number) {
return rpcZero('pause_cal_job', { task_id: taskId, addressee })
}
/** 继续运行 */
export function resumeJobApi(taskId: string, addressee: number) {
return rpcZero('unpause_cal_job', { task_id: taskId, addressee })
}
/** 停止任务 */
export function stopJobApi(taskId: string, addressee: number) {
return rpcZero('exit_cal_job', { task_id: taskId, addressee })
}
/** 获取项目下的任务 */
export function getProjectJobApi(addressee: number, pro: string) {
return rpcZero<{ state2: string | null, task: string }>('get_user_job', { addressee, pro })
}
/** 全量推送数据 */
export function pushFullDataApi(taskId: string, addressee: number) {
return rpcZero('push_full_cal_data', { task_id: taskId, addressee })
}

Binary file not shown.

View File

@@ -0,0 +1,6 @@
@font-face {
font-family: 'Oxanium';
src: url('./Oxanium.woff2') format('woff2'),
url('./Oxanium.woff') format('woff');
font-weight: 400;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

View File

@@ -0,0 +1,227 @@
@use "sass:color";
@use "sass:math";
/* Element Plus 主题变量覆盖 */
/* 基于 Element UI 变量转换为 Element Plus CSS Variables */
/* Transition
-------------------------- */
$--all-transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !default;
$--fade-transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1) !default;
$--fade-linear-transition: opacity 200ms linear !default;
$--md-fade-transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1),
opacity 300ms cubic-bezier(0.23, 1, 0.32, 1) !default;
$--border-transition-base: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1) !default;
$--color-transition-base: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1) !default;
/* Color
-------------------------- */
$--color-primary: #1677ff !default;
$--color-white: #ffffff !default;
$--color-black: #000000 !default;
// Primary color variants
$--color-primary-light-1: color.mix($--color-white, $--color-primary, 10%) !default;
$--color-primary-light-2: color.mix($--color-white, $--color-primary, 20%) !default;
$--color-primary-light-3: color.mix($--color-white, $--color-primary, 30%) !default;
$--color-primary-light-4: color.mix($--color-white, $--color-primary, 40%) !default;
$--color-primary-light-5: color.mix($--color-white, $--color-primary, 50%) !default;
$--color-primary-light-6: color.mix($--color-white, $--color-primary, 60%) !default;
$--color-primary-light-7: color.mix($--color-white, $--color-primary, 70%) !default;
$--color-primary-light-8: color.mix($--color-white, $--color-primary, 80%) !default;
$--color-primary-light-9: color.mix($--color-white, $--color-primary, 90%) !default;
// Functional colors
$--color-success: #0ac05e !default;
$--color-warning: #faad14 !default;
$--color-danger: #ff4d4f !default;
$--color-info: #8c8c8c !default;
$--color-success-light: color.mix($--color-white, $--color-success, 80%) !default;
$--color-warning-light: color.mix($--color-white, $--color-warning, 80%) !default;
$--color-danger-light: color.mix($--color-white, $--color-danger, 80%) !default;
$--color-info-light: color.mix($--color-white, $--color-info, 80%) !default;
$--color-success-lighter: color.mix($--color-white, $--color-success, 90%) !default;
$--color-warning-lighter: color.mix($--color-white, $--color-warning, 90%) !default;
$--color-danger-lighter: color.mix($--color-white, $--color-danger, 90%) !default;
$--color-info-lighter: color.mix($--color-white, $--color-info, 90%) !default;
// Text colors
$--color-text-primary: #262626 !default;
$--color-text-regular: #595959 !default;
$--color-text-secondary: #8c8c8c !default;
$--color-text-placeholder: #bfbfbf !default;
// Border colors
$--border-color-base: #d9d9d9 !default;
$--border-color-light: #e8e8e8 !default;
$--border-color-lighter: #e8e8e8 !default;
$--border-color-extra-light: #f5f5f5 !default;
// Background
$--background-color-base: #f5f5f5 !default;
/* Border
-------------------------- */
$--border-width-base: 1px !default;
$--border-style-base: solid !default;
$--border-color-hover: $--color-text-placeholder !default;
$--border-base: $--border-width-base $--border-style-base $--border-color-base !default;
$--border-radius-base: 6px !default;
$--border-radius-small: 4px !default;
$--border-radius-circle: 100% !default;
$--border-radius-zero: 0 !default;
/* Box-shadow
-------------------------- */
$--box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.08) !default;
$--box-shadow-dark: 0 6px 16px rgba(0, 0, 0, 0.12) !default;
$--box-shadow-light: 0 4px 12px rgba(0, 0, 0, 0.1) !default;
/* Typography
-------------------------- */
$--font-size-extra-large: 20px !default;
$--font-size-large: 18px !default;
$--font-size-medium: 16px !default;
$--font-size-base: 14px !default;
$--font-size-small: 13px !default;
$--font-size-extra-small: 12px !default;
$--font-weight-primary: 500 !default;
$--font-weight-secondary: 100 !default;
$--font-line-height-primary: 24px !default;
$--font-line-height-secondary: 16px !default;
/* z-index
-------------------------- */
$--index-normal: 1 !default;
$--index-top: 1000 !default;
$--index-popper: 2000 !default;
/* Disabled
-------------------------- */
$--disabled-fill-base: $--background-color-base !default;
$--disabled-color-base: $--color-text-placeholder !default;
$--disabled-border-base: $--border-color-light !default;
/* Element Plus CSS Variables Override
-------------------------- */
:root {
// Colors
--el-color-primary: #{$--color-primary};
--el-color-primary-light-3: #{$--color-primary-light-3};
--el-color-primary-light-5: #{$--color-primary-light-5};
--el-color-primary-light-7: #{$--color-primary-light-7};
--el-color-primary-light-8: #{$--color-primary-light-8};
--el-color-primary-light-9: #{$--color-primary-light-9};
--el-color-primary-dark-2: #{color.mix($--color-black, $--color-primary, 20%)};
--el-color-success: #{$--color-success};
--el-color-success-light-3: #{color.mix($--color-white, $--color-success, 30%)};
--el-color-success-light-5: #{color.mix($--color-white, $--color-success, 50%)};
--el-color-success-light-7: #{color.mix($--color-white, $--color-success, 70%)};
--el-color-success-light-8: #{color.mix($--color-white, $--color-success, 80%)};
--el-color-success-light-9: #{color.mix($--color-white, $--color-success, 90%)};
--el-color-warning: #{$--color-warning};
--el-color-warning-light-3: #{color.mix($--color-white, $--color-warning, 30%)};
--el-color-warning-light-5: #{color.mix($--color-white, $--color-warning, 50%)};
--el-color-warning-light-7: #{color.mix($--color-white, $--color-warning, 70%)};
--el-color-warning-light-8: #{color.mix($--color-white, $--color-warning, 80%)};
--el-color-warning-light-9: #{color.mix($--color-white, $--color-warning, 90%)};
--el-color-danger: #{$--color-danger};
--el-color-danger-light-3: #{color.mix($--color-white, $--color-danger, 30%)};
--el-color-danger-light-5: #{color.mix($--color-white, $--color-danger, 50%)};
--el-color-danger-light-7: #{color.mix($--color-white, $--color-danger, 70%)};
--el-color-danger-light-8: #{color.mix($--color-white, $--color-danger, 80%)};
--el-color-danger-light-9: #{color.mix($--color-white, $--color-danger, 90%)};
--el-color-info: #{$--color-info};
--el-color-info-light-3: #{color.mix($--color-white, $--color-info, 30%)};
--el-color-info-light-5: #{color.mix($--color-white, $--color-info, 50%)};
--el-color-info-light-7: #{color.mix($--color-white, $--color-info, 70%)};
--el-color-info-light-8: #{color.mix($--color-white, $--color-info, 80%)};
--el-color-info-light-9: #{color.mix($--color-white, $--color-info, 90%)};
// Text colors
--el-text-color-primary: #{$--color-text-primary};
--el-text-color-regular: #{$--color-text-regular};
--el-text-color-secondary: #{$--color-text-secondary};
--el-text-color-placeholder: #{$--color-text-placeholder};
--el-text-color-disabled: #{$--disabled-color-base};
// Border colors
--el-border-color: #{$--border-color-base};
--el-border-color-light: #{$--border-color-light};
--el-border-color-lighter: #{$--border-color-lighter};
--el-border-color-extra-light: #{$--border-color-extra-light};
--el-border-color-dark: #{color.mix($--color-black, $--border-color-base, 20%)};
--el-border-color-darker: #{color.mix($--color-black, $--border-color-base, 40%)};
// Fill colors
--el-fill-color: #{$--background-color-base};
--el-fill-color-light: #{color.mix($--color-white, $--background-color-base, 30%)};
--el-fill-color-lighter: #{color.mix($--color-white, $--background-color-base, 50%)};
--el-fill-color-extra-light: #{color.mix($--color-white, $--background-color-base, 70%)};
--el-fill-color-dark: #{color.mix($--color-black, $--background-color-base, 10%)};
--el-fill-color-darker: #{color.mix($--color-black, $--background-color-base, 20%)};
--el-fill-color-blank: #{$--color-white};
// Background colors
--el-bg-color: #{$--color-white};
--el-bg-color-page: #{$--background-color-base};
--el-bg-color-overlay: #{$--color-white};
// Border
--el-border-width: #{$--border-width-base};
--el-border-style: #{$--border-style-base};
--el-border-radius-base: #{$--border-radius-base};
--el-border-radius-small: #{$--border-radius-small};
--el-border-radius-round: 20px;
--el-border-radius-circle: #{$--border-radius-circle};
// Box-shadow
--el-box-shadow: #{$--box-shadow-base};
--el-box-shadow-light: #{$--box-shadow-light};
--el-box-shadow-lighter: 0 2px 4px rgba(0, 0, 0, 0.04);
--el-box-shadow-dark: #{$--box-shadow-dark};
// Typography
--el-font-size-extra-large: #{$--font-size-extra-large};
--el-font-size-large: #{$--font-size-large};
--el-font-size-medium: #{$--font-size-medium};
--el-font-size-base: #{$--font-size-base};
--el-font-size-small: #{$--font-size-small};
--el-font-size-extra-small: #{$--font-size-extra-small};
--el-font-weight-primary: #{$--font-weight-primary};
--el-font-line-height-primary: #{$--font-line-height-primary};
// z-index
--el-index-normal: #{$--index-normal};
--el-index-top: #{$--index-top};
--el-index-popper: #{$--index-popper};
// Transition
--el-transition-duration: 0.3s;
--el-transition-duration-fast: 0.2s;
--el-transition-function-ease-in-out-bezier: cubic-bezier(0.645, 0.045, 0.355, 1);
--el-transition-function-fast-bezier: cubic-bezier(0.23, 1, 0.32, 1);
--el-transition-all: #{$--all-transition};
--el-transition-fade: #{$--fade-transition};
--el-transition-fade-linear: #{$--fade-linear-transition};
--el-transition-md-fade: #{$--md-fade-transition};
--el-transition-border: #{$--border-transition-base};
--el-transition-color: #{$--color-transition-base};
// Component size
--el-component-size-large: 40px;
--el-component-size: 32px;
--el-component-size-small: 24px;
// Disabled
--el-disabled-bg-color: #{$--disabled-fill-base};
--el-disabled-text-color: #{$--disabled-color-base};
--el-disabled-border-color: #{$--disabled-border-base};
}

View File

@@ -0,0 +1,41 @@
@use './variables.scss' as *;
@use './element-plus-theme.scss';
@import url('../fonts/index.css');
/* Reset or Base styles */
body {
background-color: $bg-color-page;
color: $text-color-primary;
}
* {
box-sizing: border-box;
}
/* Scrollbar customization */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: $border-color;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: $text-color-secondary;
}
/* DCS 画布组件动画 */
@keyframes dcs-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
.dcs-blink {
animation: dcs-blink 1s ease-in-out infinite;
}

View File

@@ -0,0 +1,18 @@
/* Global SCSS Variables */
$primary-color: #1677ff;
$success-color: #0ac05e;
$warning-color: #faad14;
$danger-color: #ff4d4f;
$info-color: #8c8c8c;
$text-color-primary: #262626;
$text-color-regular: #595959;
$text-color-secondary: #8c8c8c;
$text-color-placeholder: #bfbfbf;
$border-color: #d9d9d9;
$border-color-light: #e8e8e8;
$border-color-lighter: #e8e8e8;
$bg-color: #ffffff;
$bg-color-page: #f5f5f5;

11
packages/core/src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const ElMessage: typeof import('element-plus/es')['ElMessage']
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
}

View File

@@ -0,0 +1,23 @@
import type { IPlatformBridge } from '@cslab-dcs/bridge'
import { bridge as defaultBridge } from '@cslab-dcs/bridge'
import { inject, onMounted } from 'vue'
import pinia from '@/stores'
import { useAppStore } from '@/stores/app'
export function useBridge() {
const bridge = inject<IPlatformBridge>('bridge', defaultBridge)
const appStore = useAppStore(pinia)
onMounted(async () => {
const platformInfo = await bridge.system.getPlatformInfo()
appStore.setPlatformInfo(platformInfo)
const appVersion = await bridge.system.getAppVersion()
appStore.setAppVersion(appVersion)
})
return {
bridge,
}
}

View File

@@ -0,0 +1,64 @@
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import pinia, { useCanvasStore, useLayerStore, useProjectStore } from '@/stores'
export function useEditorWatchEffect() {
const projectStore = useProjectStore(pinia)
const { projectId } = storeToRefs(projectStore)
const canvasStore = useCanvasStore(pinia)
const { canvasId } = storeToRefs(canvasStore)
const layerStore = useLayerStore(pinia)
function resetCanvas() {
canvasStore.setCanvasId(null)
canvasStore.setCanvasList([])
}
async function refreshCanvasAndLayer(preferredCanvasId?: string | null) {
const canvases = await canvasStore.getCanvasList()
const selectedCanvas = preferredCanvasId
? canvases.find(canvas => canvas.id === preferredCanvasId)
: undefined
const nextCanvas = selectedCanvas || canvases[0] || null
canvasStore.setCanvasId(nextCanvas?.id || null)
}
watch(
() => projectId.value,
async (nextProjectId) => {
// 切换项目时重置画布和图层状态
const preferredCanvasId = canvasId.value
resetCanvas()
if (!nextProjectId) {
return
}
// 获取新项目的项目信息,更新项目名称等相关状态
await projectStore.getProjectInfo()
// 获取新项目的画布列表,并尽量保留 URL / store 中指定的画布
await refreshCanvasAndLayer(preferredCanvasId)
},
{ immediate: true },
)
function resetLayer() {
layerStore.setLayerId('')
layerStore.replaceLayers([])
}
watch(
() => canvasId.value,
async () => {
// 切换画布时重置图层状态
resetLayer()
// 获取新画布的信息
await canvasStore.getCanvasInfo()
},
{ immediate: true },
)
}

View File

@@ -0,0 +1,3 @@
export * from './bridge'
export * from './editor'
export * from './route-state'

View File

@@ -0,0 +1,52 @@
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getRouteCanvasId, getRouteProjectId, mergeRouteStateQuery, shouldAttachCanvasId } from '@/router/route-state'
import { useCanvasStore, useProjectStore } from '@/stores'
export function useRouteStateSync() {
const route = useRoute()
const router = useRouter()
const projectStore = useProjectStore()
const canvasStore = useCanvasStore()
const { projectId } = storeToRefs(projectStore)
const { canvasId } = storeToRefs(canvasStore)
watch(
[projectId, canvasId, () => route.fullPath],
async () => {
const nextProjectId = projectId.value || ''
const nextCanvasId = canvasId.value || ''
const routeProjectId = getRouteProjectId(route.query)
const routeCanvasId = getRouteCanvasId(route.query)
const includeCanvasId = shouldAttachCanvasId(route)
// 首次刷新时优先从当前 URL 恢复上下文,避免 store 尚未就绪时把 query 擦掉。
if (!nextProjectId && routeProjectId) {
projectStore.setProjectId(routeProjectId)
return
}
if (includeCanvasId && !nextCanvasId && routeCanvasId) {
canvasStore.setCanvasId(routeCanvasId)
return
}
if (routeProjectId === nextProjectId && routeCanvasId === nextCanvasId) {
return
}
await router.replace({
path: route.path,
query: mergeRouteStateQuery(route.query, {
projectId: nextProjectId,
canvasId: nextCanvasId,
}, { includeCanvasId }),
hash: route.hash,
})
},
{ immediate: true },
)
}

41
packages/core/src/components.d.ts vendored Normal file
View File

@@ -0,0 +1,41 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCol: typeof import('element-plus/es')['ElCol']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag']
ElUpload: typeof import('element-plus/es')['ElUpload']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { createCanvasApi, updateCanvasApi } from '@/api'
import { useCanvasStore } from '@/stores'
import { useCanvasPageInject } from '../context'
const { showPageDialog, editingPageId, changePageDialogVisible } = useCanvasPageInject()
const isEdit = computed(() => !!editingPageId.value)
const canvasStore = useCanvasStore()
const { canvasList } = storeToRefs(canvasStore)
const pageDialogName = ref('')
const pageDialogDescription = ref('')
const isSubmittingPageDialog = ref(false)
function getNextCanvasName() {
const baseName = 'DCS图'
const existingNames = new Set(canvasList.value.map(page => page.name))
if (!existingNames.has(baseName)) {
return baseName + (canvasList.value.length + 1)
}
let counter = 2
let nextName = `${baseName}${counter}`
while (existingNames.has(nextName)) {
counter += 1
nextName = `${baseName}${counter}`
}
return nextName
}
watch(() => showPageDialog.value, (next) => {
if (next && !isEdit.value) {
pageDialogName.value = getNextCanvasName()
}
})
function onPageDialogClosed() {
pageDialogName.value = ''
pageDialogDescription.value = ''
}
const router = useRouter()
const route = useRoute()
async function submitPageDialog() {
const name = pageDialogName.value.trim()
if (!name) {
ElMessage.warning('请输入画布名称')
return
}
if (isSubmittingPageDialog.value) {
return
}
isSubmittingPageDialog.value = true
try {
if (!isEdit.value) {
const createdCanvas = await createCanvasApi({ name, description: pageDialogDescription.value })
await canvasStore.getCanvasList()
changePageDialogVisible(false)
await router.push({
path: route.path,
query: {
...route.query,
canvasId: createdCanvas.id,
},
})
ElMessage.success('创建成功')
return
}
const pageId = editingPageId.value
if (!pageId) {
return
}
await updateCanvasApi({
id: pageId,
name,
description: pageDialogDescription.value,
})
await canvasStore.getCanvasList()
changePageDialogVisible(false)
ElMessage.success('修改成功')
}
catch (error) {
console.error('[canvas] failed to submit page dialog', error)
ElMessage.error(isEdit.value ? '修改失败' : '创建失败')
}
finally {
isSubmittingPageDialog.value = false
}
}
</script>
<template>
<el-dialog
v-model="showPageDialog" :title="isEdit ? '重命名DCS图' : '添加DCS图'" width="420px"
@closed="onPageDialogClosed"
>
<el-form label-width="60px">
<el-form-item label="名称" required>
<el-input v-model="pageDialogName" maxlength="64" placeholder="请输入DCS图名称" show-word-limit />
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="pageDialogDescription"
type="textarea"
:rows="4"
:maxlength="200"
show-word-limit
placeholder="请输入描述(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="isSubmittingPageDialog" @click="changePageDialogVisible(false)">
取消
</el-button>
<el-button type="primary" :loading="isSubmittingPageDialog" @click="submitPageDialog">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>

View File

@@ -0,0 +1,779 @@
import type { Layer } from '../types'
import type { RectLike } from '../utils'
import { createLayerGroupId, getLayerGroupMeta, withLayerGroupMeta } from '../grouping'
import { rectIntersects } from '../utils'
export interface LayerTransformResult {
changed: boolean
nextLayers: Layer[]
groupId?: string | null
}
export interface LayerOrderItem {
kind: 'group' | 'component'
id: string
groupId?: string
}
export type LayerMovePlacement = 'before' | 'after' | 'into'
export type LayerMoveEdge = 'front' | 'back'
export type LayerMoveDirection = 'forward' | 'backward'
const GROUP_NAME_PATTERN = /^分组\s*(\d+)$/
function uniqueLayerIds(layerIds: string[]) {
return [...new Set(layerIds)].filter(Boolean)
}
export function canGroupLayersByIds(layers: Layer[], layerIds: string[]) {
const selectedIds = uniqueLayerIds(layerIds)
if (selectedIds.length < 1) {
return false
}
const selectedLayers = layers.filter(layer => selectedIds.includes(layer.id))
if (selectedLayers.length !== selectedIds.length) {
return false
}
return selectedLayers.every(layer => !getLayerGroupMeta(layer))
}
function isSameOrder(left: Layer[], right: Layer[]) {
if (left.length !== right.length) {
return false
}
return left.every((item, index) => item.id === right[index]?.id)
}
interface LayerOrderBlock {
kind: 'group' | 'component'
id: string
groupId?: string
layerIds: string[]
}
interface OrderedEntryWithBounds<T> {
entry: T
bounds: RectLike
}
function resolveOrderItemKey(item: LayerOrderItem | LayerOrderBlock) {
return item.kind === 'group'
? `group:${item.groupId || item.id}`
: item.id
}
function resolveOrderItemParentKey(item: LayerOrderItem) {
if (item.kind === 'group') {
return 'root'
}
return item.groupId ? `group:${item.groupId}` : 'root'
}
function normalizeLayerOrderItems(items: LayerOrderItem[]) {
const uniqueItems: LayerOrderItem[] = []
const seenKeys = new Set<string>()
for (const item of items) {
const key = resolveOrderItemKey(item)
if (seenKeys.has(key)) {
continue
}
seenKeys.add(key)
uniqueItems.push(item)
}
if (!uniqueItems.length) {
return null
}
const parentKey = resolveOrderItemParentKey(uniqueItems[0])
// 只允许同一层级内一起移动,避免根节点和组内节点混排造成顺序错乱。
if (uniqueItems.some(item => resolveOrderItemParentKey(item) !== parentKey)) {
return null
}
return {
items: uniqueItems,
parentKey,
selectedKeys: new Set(uniqueItems.map(item => resolveOrderItemKey(item))),
}
}
function buildLayerOrderBlocks(layers: Layer[]) {
const groupBlocks = new Map<string, LayerOrderBlock>()
const blocks: LayerOrderBlock[] = []
layers.forEach((layer) => {
const group = getLayerGroupMeta(layer)
if (!group) {
blocks.push({
kind: 'component',
id: layer.id,
layerIds: [layer.id],
})
return
}
const existingBlock = groupBlocks.get(group.groupId)
if (existingBlock) {
// 分组在图层排序里视为一个整体块,内部成员顺序单独维护。
existingBlock.layerIds.push(layer.id)
return
}
const nextBlock: LayerOrderBlock = {
kind: 'group',
id: `group:${group.groupId}`,
groupId: group.groupId,
layerIds: [layer.id],
}
groupBlocks.set(group.groupId, nextBlock)
blocks.push(nextBlock)
})
return blocks
}
function mapLayersById(layers: Layer[]) {
return new Map(layers.map(layer => [layer.id, layer] as const))
}
function flattenBlocksToLayers(layers: Layer[], blocks: LayerOrderBlock[]) {
const layerMap = mapLayersById(layers)
return blocks.flatMap(block => block.layerIds.map(layerId => layerMap.get(layerId)).filter((layer): layer is Layer => Boolean(layer)))
}
function resolveLayerBounds(layer: Layer): RectLike {
return {
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
}
}
function mergeRectBounds(boundsList: RectLike[]) {
const [firstBound, ...restBounds] = boundsList
if (!firstBound) {
return null
}
let left = firstBound.x
let top = firstBound.y
let right = firstBound.x + firstBound.width
let bottom = firstBound.y + firstBound.height
restBounds.forEach((bound) => {
left = Math.min(left, bound.x)
top = Math.min(top, bound.y)
right = Math.max(right, bound.x + bound.width)
bottom = Math.max(bottom, bound.y + bound.height)
})
return {
x: left,
y: top,
width: right - left,
height: bottom - top,
}
}
function resolveBlockBounds(layers: Layer[], block: LayerOrderBlock) {
const layerMap = mapLayersById(layers)
const boundsList = block.layerIds
.map(layerId => layerMap.get(layerId))
.filter((layer): layer is Layer => Boolean(layer))
.map(resolveLayerBounds)
return mergeRectBounds(boundsList)
}
function moveSelectedEntriesToEdge<T>(
items: T[],
isSelected: (item: T) => boolean,
edge: LayerMoveEdge,
) {
const selected: T[] = []
const unselected: T[] = []
items.forEach((item) => {
if (isSelected(item)) {
selected.push(item)
return
}
unselected.push(item)
})
if (!selected.length || !unselected.length) {
return items
}
return edge === 'back'
? [...selected, ...unselected]
: [...unselected, ...selected]
}
function moveSelectedEntriesByStep<T>(
items: T[],
isSelected: (item: T) => boolean,
direction: LayerMoveDirection,
) {
const nextItems = [...items]
if (direction === 'forward') {
for (let index = nextItems.length - 2; index >= 0; index -= 1) {
if (!isSelected(nextItems[index]) || isSelected(nextItems[index + 1])) {
continue
}
;[nextItems[index], nextItems[index + 1]] = [nextItems[index + 1], nextItems[index]]
}
return nextItems
}
for (let index = 1; index < nextItems.length; index += 1) {
if (!isSelected(nextItems[index]) || isSelected(nextItems[index - 1])) {
continue
}
;[nextItems[index - 1], nextItems[index]] = [nextItems[index], nextItems[index - 1]]
}
return nextItems
}
function moveSelectedEntriesAroundTarget<T>(
items: T[],
isSelected: (item: T) => boolean,
targetIndex: number,
direction: LayerMoveDirection,
) {
const nextItems = [...items]
const selectedEntries = nextItems.filter(isSelected)
if (!selectedEntries.length || targetIndex < 0) {
return items
}
const remainingItems = nextItems.filter(item => !isSelected(item))
const targetEntry = items[targetIndex]
const nextTargetIndex = remainingItems.findIndex(item => item === targetEntry)
if (nextTargetIndex < 0) {
return items
}
const insertionIndex = direction === 'forward' ? nextTargetIndex + 1 : nextTargetIndex
remainingItems.splice(insertionIndex, 0, ...selectedEntries)
return remainingItems
}
function findClosestOverlapTargetIndex<T>(
entries: Array<OrderedEntryWithBounds<T>>,
isSelected: (entry: T) => boolean,
direction: LayerMoveDirection,
) {
const selectedEntries = entries.filter(item => isSelected(item.entry))
if (!selectedEntries.length) {
return null
}
const selectedBounds = selectedEntries.map(item => item.bounds)
const searchStart = direction === 'forward'
? Math.max(...entries.map((item, index) => (isSelected(item.entry) ? index : -1)))
: Math.min(...entries.map((item, index) => (isSelected(item.entry) ? index : Number.POSITIVE_INFINITY)))
if (!Number.isFinite(searchStart)) {
return null
}
if (direction === 'forward') {
// 优先寻找与选中块几何上相交的目标,移动体验更符合“视觉前后”的直觉。
for (let index = searchStart + 1; index < entries.length; index += 1) {
const candidate = entries[index]
if (isSelected(candidate.entry)) {
continue
}
if (selectedBounds.some(bound => rectIntersects(bound, candidate.bounds))) {
return index
}
}
return null
}
for (let index = searchStart - 1; index >= 0; index -= 1) {
const candidate = entries[index]
if (isSelected(candidate.entry)) {
continue
}
if (selectedBounds.some(bound => rectIntersects(bound, candidate.bounds))) {
return index
}
}
return null
}
function resolveNextGroupName(layers: Layer[]) {
// 复用“分组N”序列避免每次新建都出现随机名。
const maxGroupIndex = layers.reduce((max, layer) => {
const groupName = getLayerGroupMeta(layer)?.groupName?.trim()
if (!groupName) {
return max
}
const matched = GROUP_NAME_PATTERN.exec(groupName)
if (!matched) {
return max
}
const value = Number.parseInt(matched[1], 10)
return Number.isFinite(value) ? Math.max(max, value) : max
}, 0)
return `分组${maxGroupIndex + 1}`
}
export function reorderLayersToFront(layers: Layer[], layerIds: string[]): LayerTransformResult {
const selectedSet = new Set(uniqueLayerIds(layerIds))
if (!selectedSet.size) {
return { changed: false, nextLayers: layers }
}
const selected: Layer[] = []
const unselected: Layer[] = []
layers.forEach((layer) => {
if (selectedSet.has(layer.id)) {
selected.push(layer)
return
}
unselected.push(layer)
})
if (!selected.length) {
return { changed: false, nextLayers: layers }
}
const nextLayers = [...unselected, ...selected]
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
export function reorderLayersToBack(layers: Layer[], layerIds: string[]): LayerTransformResult {
const selectedSet = new Set(uniqueLayerIds(layerIds))
if (!selectedSet.size) {
return { changed: false, nextLayers: layers }
}
const selected: Layer[] = []
const unselected: Layer[] = []
layers.forEach((layer) => {
if (selectedSet.has(layer.id)) {
selected.push(layer)
return
}
unselected.push(layer)
})
if (!selected.length) {
return { changed: false, nextLayers: layers }
}
const nextLayers = [...selected, ...unselected]
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
export function reorderLayerBefore(layers: Layer[], sourceId: string, targetId: string): LayerTransformResult {
if (!sourceId || !targetId || sourceId === targetId) {
return { changed: false, nextLayers: layers }
}
const sourceIndex = layers.findIndex(layer => layer.id === sourceId)
const targetIndex = layers.findIndex(layer => layer.id === targetId)
if (sourceIndex < 0 || targetIndex < 0) {
return { changed: false, nextLayers: layers }
}
const nextLayers = [...layers]
const [sourceLayer] = nextLayers.splice(sourceIndex, 1)
if (!sourceLayer) {
return { changed: false, nextLayers: layers }
}
const nextTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
nextLayers.splice(nextTargetIndex, 0, sourceLayer)
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
// 跨父级移动:组件在根级 / 组之间自由拖拽。
function moveLayerOrderItemCrossParent(
layers: Layer[],
source: LayerOrderItem,
target: LayerOrderItem,
placement: LayerMovePlacement,
): LayerTransformResult {
// 只有组件才允许跨组移动,分组整体不能被拖入另一个分组。
if (source.kind !== 'component') {
return { changed: false, nextLayers: layers }
}
const sourceLayer = layers.find(l => l.id === source.id)
if (!sourceLayer) {
return { changed: false, nextLayers: layers }
}
// 确定目标分组into → 进入目标组;拖到组成员旁 → 加入该组;否则 → 脱组到根级。
let targetGroupMeta: { groupId: string, groupName: string } | null = null
if (placement === 'into' && target.kind === 'group' && target.groupId) {
const member = layers.find(l => getLayerGroupMeta(l)?.groupId === target.groupId)
const meta = member ? getLayerGroupMeta(member) : null
if (meta) {
targetGroupMeta = { groupId: meta.groupId, groupName: meta.groupName }
}
}
else if (target.kind === 'component' && target.groupId) {
const targetLayer = layers.find(l => l.id === target.id)
const meta = targetLayer ? getLayerGroupMeta(targetLayer) : null
if (meta) {
targetGroupMeta = { groupId: meta.groupId, groupName: meta.groupName }
}
}
const updatedSource = withLayerGroupMeta(sourceLayer, targetGroupMeta)
const withoutSource = layers.filter(l => l.id !== source.id)
let insertionIndex: number
if (placement === 'into' && target.kind === 'group' && target.groupId) {
// 放入组末尾(数组末端 = 面板中组内最上方 = 最高 z-index
let lastIdx = -1
withoutSource.forEach((l, i) => {
if (getLayerGroupMeta(l)?.groupId === target.groupId) {
lastIdx = i
}
})
insertionIndex = lastIdx >= 0 ? lastIdx + 1 : withoutSource.length
}
else if (target.kind === 'group' && target.groupId) {
// 拖到组头行的 before / after → 根级排序,图层不进组
const groupIndices: number[] = []
withoutSource.forEach((l, i) => {
if (getLayerGroupMeta(l)?.groupId === target.groupId) {
groupIndices.push(i)
}
})
if (!groupIndices.length) {
return { changed: false, nextLayers: layers }
}
insertionIndex = placement === 'before'
? Math.min(...groupIndices)
: Math.max(...groupIndices) + 1
}
else {
// 拖到普通组件旁
const targetIdx = withoutSource.findIndex(l => l.id === target.id)
if (targetIdx < 0) {
return { changed: false, nextLayers: layers }
}
insertionIndex = placement === 'before' ? targetIdx : targetIdx + 1
}
const nextLayers = [...withoutSource]
nextLayers.splice(insertionIndex, 0, updatedSource)
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
export function moveLayerOrderItem(
layers: Layer[],
source: LayerOrderItem,
target: LayerOrderItem,
placement: LayerMovePlacement,
): LayerTransformResult {
if (!source.id || !target.id) {
return { changed: false, nextLayers: layers }
}
const sourceParentKey = resolveOrderItemParentKey(source)
const targetParentKey = resolveOrderItemParentKey(target)
// 跨父级移动或 into 放入分组
if (sourceParentKey !== targetParentKey || placement === 'into') {
return moveLayerOrderItemCrossParent(layers, source, target, placement)
}
const blocks = buildLayerOrderBlocks(layers)
if (sourceParentKey === 'root') {
// 根层级拖拽时,分组整体和独立图层都作为 block 参与排序。
const sourceKey = resolveOrderItemKey(source)
const targetKey = resolveOrderItemKey(target)
if (sourceKey === targetKey) {
return { changed: false, nextLayers: layers }
}
const sourceIndex = blocks.findIndex(block => resolveOrderItemKey(block) === sourceKey)
const targetIndex = blocks.findIndex(block => resolveOrderItemKey(block) === targetKey)
if (sourceIndex < 0 || targetIndex < 0) {
return { changed: false, nextLayers: layers }
}
const nextBlocks = [...blocks]
const [sourceBlock] = nextBlocks.splice(sourceIndex, 1)
if (!sourceBlock) {
return { changed: false, nextLayers: layers }
}
const nextTargetIndex = nextBlocks.findIndex(block => resolveOrderItemKey(block) === targetKey)
if (nextTargetIndex < 0) {
return { changed: false, nextLayers: layers }
}
const insertionIndex = placement === 'before' ? nextTargetIndex : nextTargetIndex + 1
nextBlocks.splice(insertionIndex, 0, sourceBlock)
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
if (source.kind !== 'component' || target.kind !== 'component' || !source.groupId || source.groupId !== target.groupId) {
return { changed: false, nextLayers: layers }
}
const targetGroupIndex = blocks.findIndex(block => block.kind === 'group' && block.groupId === source.groupId)
if (targetGroupIndex < 0) {
return { changed: false, nextLayers: layers }
}
const targetGroupBlock = blocks[targetGroupIndex]
const sourceIndex = targetGroupBlock.layerIds.findIndex(layerId => layerId === source.id)
const targetIndex = targetGroupBlock.layerIds.findIndex(layerId => layerId === target.id)
if (sourceIndex < 0 || targetIndex < 0 || source.id === target.id) {
return { changed: false, nextLayers: layers }
}
const nextGroupLayerIds = [...targetGroupBlock.layerIds]
const [sourceLayerId] = nextGroupLayerIds.splice(sourceIndex, 1)
if (!sourceLayerId) {
return { changed: false, nextLayers: layers }
}
const nextTargetIndex = nextGroupLayerIds.findIndex(layerId => layerId === target.id)
if (nextTargetIndex < 0) {
return { changed: false, nextLayers: layers }
}
const insertionIndex = placement === 'before' ? nextTargetIndex : nextTargetIndex + 1
nextGroupLayerIds.splice(insertionIndex, 0, sourceLayerId)
const nextBlocks = [...blocks]
nextBlocks[targetGroupIndex] = {
...targetGroupBlock,
layerIds: nextGroupLayerIds,
}
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
export function moveLayerOrderItemsToEdge(
layers: Layer[],
selectedItems: LayerOrderItem[],
edge: LayerMoveEdge,
): LayerTransformResult {
const normalized = normalizeLayerOrderItems(selectedItems)
if (!normalized) {
return { changed: false, nextLayers: layers }
}
const blocks = buildLayerOrderBlocks(layers)
const isSelectedBlock = (block: LayerOrderBlock) => normalized.selectedKeys.has(resolveOrderItemKey(block))
if (normalized.parentKey === 'root') {
const nextBlocks = moveSelectedEntriesToEdge(blocks, isSelectedBlock, edge)
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
const groupId = normalized.items[0]?.groupId
if (!groupId) {
return { changed: false, nextLayers: layers }
}
const targetGroupIndex = blocks.findIndex(block => block.kind === 'group' && block.groupId === groupId)
if (targetGroupIndex < 0) {
return { changed: false, nextLayers: layers }
}
const targetGroupBlock = blocks[targetGroupIndex]
const nextGroupLayerIds = moveSelectedEntriesToEdge(
targetGroupBlock.layerIds,
layerId => normalized.selectedKeys.has(layerId),
edge,
)
const nextBlocks = [...blocks]
nextBlocks[targetGroupIndex] = {
...targetGroupBlock,
layerIds: nextGroupLayerIds,
}
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
export function moveLayerOrderItemsByStep(
layers: Layer[],
selectedItems: LayerOrderItem[],
direction: LayerMoveDirection,
): LayerTransformResult {
const normalized = normalizeLayerOrderItems(selectedItems)
if (!normalized) {
return { changed: false, nextLayers: layers }
}
const blocks = buildLayerOrderBlocks(layers)
const isSelectedBlock = (block: LayerOrderBlock) => normalized.selectedKeys.has(resolveOrderItemKey(block))
if (normalized.parentKey === 'root') {
// 根层级优先按几何重叠关系找“下一站”,否则退回到纯顺序移动。
const rootEntries = blocks
.map(block => ({
entry: block,
bounds: resolveBlockBounds(layers, block),
}))
.filter((item): item is OrderedEntryWithBounds<LayerOrderBlock> => Boolean(item.bounds))
const overlapTargetIndex = findClosestOverlapTargetIndex(rootEntries, isSelectedBlock, direction)
const nextBlocks = overlapTargetIndex === null
? moveSelectedEntriesByStep(blocks, isSelectedBlock, direction)
: moveSelectedEntriesAroundTarget(blocks, isSelectedBlock, overlapTargetIndex, direction)
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
const groupId = normalized.items[0]?.groupId
if (!groupId) {
return { changed: false, nextLayers: layers }
}
const targetGroupIndex = blocks.findIndex(block => block.kind === 'group' && block.groupId === groupId)
if (targetGroupIndex < 0) {
return { changed: false, nextLayers: layers }
}
const targetGroupBlock = blocks[targetGroupIndex]
const layerMap = mapLayersById(layers)
const groupEntries = targetGroupBlock.layerIds
.map((layerId) => {
const layer = layerMap.get(layerId)
if (!layer) {
return null
}
return {
entry: layerId,
bounds: resolveLayerBounds(layer),
}
})
.filter((item): item is OrderedEntryWithBounds<string> => Boolean(item))
const overlapTargetIndex = findClosestOverlapTargetIndex(
groupEntries,
layerId => normalized.selectedKeys.has(layerId),
direction,
)
const nextGroupLayerIds = overlapTargetIndex === null
? moveSelectedEntriesByStep(
targetGroupBlock.layerIds,
layerId => normalized.selectedKeys.has(layerId),
direction,
)
: moveSelectedEntriesAroundTarget(
targetGroupBlock.layerIds,
layerId => normalized.selectedKeys.has(layerId),
overlapTargetIndex,
direction,
)
const nextBlocks = [...blocks]
nextBlocks[targetGroupIndex] = {
...targetGroupBlock,
layerIds: nextGroupLayerIds,
}
const nextLayers = flattenBlocksToLayers(layers, nextBlocks)
return {
changed: !isSameOrder(layers, nextLayers),
nextLayers,
}
}
export function groupLayersByIds(layers: Layer[], layerIds: string[], groupName?: string): LayerTransformResult {
const selectedIds = uniqueLayerIds(layerIds)
if (!canGroupLayersByIds(layers, selectedIds)) {
return { changed: false, nextLayers: layers }
}
const selectedSet = new Set(selectedIds)
const groupId = createLayerGroupId()
const nextGroupName = groupName?.trim() || resolveNextGroupName(layers)
const selectedLayers = layers
.filter(layer => selectedSet.has(layer.id))
.map(layer => withLayerGroupMeta(layer, { groupId, groupName: nextGroupName }))
if (selectedLayers.length < 1) {
return { changed: false, nextLayers: layers }
}
const firstSelectedIndex = layers.findIndex(layer => selectedSet.has(layer.id))
const unselectedLayers = layers.filter(layer => !selectedSet.has(layer.id))
// 分组后把整组插回首次选中位置,尽量保持用户原有层级感知。
const nextLayers = [...unselectedLayers]
nextLayers.splice(firstSelectedIndex, 0, ...selectedLayers)
return {
changed: true,
nextLayers,
groupId,
}
}
export function ungroupLayersByIds(layers: Layer[], layerIds?: string[]): LayerTransformResult {
const targetSet = layerIds?.length ? new Set(uniqueLayerIds(layerIds)) : null
let changed = false
const nextLayers = layers.map((layer) => {
if (targetSet && !targetSet.has(layer.id)) {
return layer
}
if (!getLayerGroupMeta(layer)) {
return layer
}
changed = true
return withLayerGroupMeta(layer, null)
})
return {
changed,
nextLayers,
}
}

View File

@@ -0,0 +1,543 @@
import type { PointerPosition } from '../context'
import type { Layer } from '../types'
import type { LayerMoveDirection, LayerMoveEdge, LayerMovePlacement, LayerOrderItem } from './layer.domain'
import { GRID_SIZE } from '@/constants'
import { useLayerStore } from '@/stores'
import { useCanvasState } from '../context'
import { getLayerGroupById, getLayerGroupMeta } from '../grouping'
import { clamp, createLayerId, snapToGrid } from '../utils'
import {
canGroupLayersByIds,
groupLayersByIds,
moveLayerOrderItem,
moveLayerOrderItemsByStep,
moveLayerOrderItemsToEdge,
reorderLayerBefore,
reorderLayersToBack,
reorderLayersToFront,
ungroupLayersByIds,
} from './layer.domain'
export function useLayerActions() {
const { layerList } = useCanvasState()
const layerStore = useLayerStore()
function applyLayerList(nextLayers: Layer[]) {
// 图层顺序和分组调整后保留当前选区,避免用户每次操作都丢选中态。
layerStore.setLayerList(nextLayers, { preserveSelection: true })
}
function toggleLayerLock(id: string, locked?: boolean) {
return layerStore.updateLayerById(id, (item) => {
const nextLocked = typeof locked === 'boolean' ? locked : !item.config?.locked
return {
...item,
config: {
...item.config,
locked: nextLocked,
},
}
})
}
function toggleLayerVisible(id: string, visible?: boolean) {
return layerStore.updateLayerById(id, (item) => {
const nextVisible = typeof visible === 'boolean' ? visible : item.config?.visible === false
return {
...item,
config: {
...item.config,
visible: nextVisible,
},
}
})
}
function bringLayersToFront(layerIds: string[]) {
const { changed, nextLayers } = reorderLayersToFront(layerList.value, layerIds)
if (!changed) {
return false
}
applyLayerList(nextLayers)
return true
}
function sendLayersToBack(layerIds: string[]) {
const { changed, nextLayers } = reorderLayersToBack(layerList.value, layerIds)
if (!changed) {
return false
}
applyLayerList(nextLayers)
return true
}
function moveLayerBefore(sourceId: string, targetId: string) {
const { changed, nextLayers } = reorderLayerBefore(layerList.value, sourceId, targetId)
if (!changed) {
return false
}
applyLayerList(nextLayers)
return true
}
function moveLayerItem(source: LayerOrderItem, target: LayerOrderItem, placement: LayerMovePlacement) {
const { changed, nextLayers } = moveLayerOrderItem(layerList.value, source, target, placement)
if (!changed) {
return false
}
applyLayerList(nextLayers)
return true
}
function moveLayerItemsToEdge(items: LayerOrderItem[], edge: LayerMoveEdge) {
const { changed, nextLayers } = moveLayerOrderItemsToEdge(layerList.value, items, edge)
if (!changed) {
return false
}
applyLayerList(nextLayers)
return true
}
function moveLayerItemsByStep(items: LayerOrderItem[], direction: LayerMoveDirection) {
const { changed, nextLayers } = moveLayerOrderItemsByStep(layerList.value, items, direction)
if (!changed) {
return false
}
applyLayerList(nextLayers)
return true
}
function canMoveLayerItemsToEdge(items: LayerOrderItem[], edge: LayerMoveEdge) {
return moveLayerOrderItemsToEdge(layerList.value, items, edge).changed
}
function canMoveLayerItemsByStep(items: LayerOrderItem[], direction: LayerMoveDirection) {
return moveLayerOrderItemsByStep(layerList.value, items, direction).changed
}
function canGroupLayers(layerIds: string[]) {
return canGroupLayersByIds(layerList.value, layerIds)
}
function groupLayers(layerIds: string[], groupName?: string) {
const { changed, nextLayers, groupId } = groupLayersByIds(layerList.value, layerIds, groupName)
if (!changed) {
return null
}
applyLayerList(nextLayers)
return groupId ?? null
}
function ungroupLayers(layerIds?: string[]) {
const { changed, nextLayers } = ungroupLayersByIds(layerList.value, layerIds)
if (!changed) {
return false
}
applyLayerList(nextLayers)
return true
}
return {
toggleLayerLock,
toggleLayerVisible,
bringLayersToFront,
sendLayersToBack,
moveLayerBefore,
moveLayerItem,
moveLayerItemsToEdge,
moveLayerItemsByStep,
canMoveLayerItemsToEdge,
canMoveLayerItemsByStep,
canGroupLayers,
groupLayers,
ungroupLayers,
}
}
export function useLayerSelection() {
const { selectedLayerId, selectedLayerIds, selectedGroup, layerList, canvasView } = useCanvasState()
const layerStore = useLayerStore()
const layerActions = useLayerActions()
function setSelection(nextIds: string[], primaryId: string | null = nextIds[0] ?? null, options?: { groupId?: string | null }) {
// 主选中项和多选数组统一从这里收口,保证面板和舞台状态一致。
layerStore.setSelectedLayerIds(nextIds, primaryId ?? '', options)
}
function clearSelection() {
layerStore.clearLayerSelection()
}
// 由外部图层面板触发,按组件 ID 精确选中
function selectLayerById(id: string) {
const exists = layerList.value.some(item => item.id === id)
if (!exists) {
return false
}
setSelection([id], id)
return true
}
// 由外部图层面板触发,多选组件并保持主选中项。
function selectLayersByIds(ids: string[], primaryId?: string | null) {
if (!ids.length) {
clearSelection()
return false
}
const existingIds = [...new Set(ids)].filter(id => layerList.value.some(item => item.id === id))
if (!existingIds.length) {
clearSelection()
return false
}
setSelection(existingIds, primaryId ?? existingIds[0] ?? null)
return true
}
function selectGroupById(groupId: string, primaryId?: string | null) {
const group = getLayerGroupById(layerList.value, groupId)
if (!group) {
clearSelection()
return false
}
setSelection(group.layerIds, primaryId ?? group.layerIds[0] ?? null, { groupId })
return true
}
async function removeSelectedLayers() {
if (!selectedLayerIds.value.length) {
return
}
// 批量删除3个及以上时弹出二次确认
if (selectedLayerIds.value.length >= 3) {
try {
const { ElMessageBox } = await import('element-plus')
await ElMessageBox.confirm(
`确定删除选中的 ${selectedLayerIds.value.length} 个图层吗?此操作不可撤销。`,
'批量删除',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
)
}
catch {
return
}
}
layerStore.removeLayersByIds(selectedLayerIds.value)
clearSelection()
}
function getSelectedLayers() {
const fallbackIds = selectedLayerIds.value.length ? selectedLayerIds.value : selectedLayerId.value ? [selectedLayerId.value] : []
const selectedSet = new Set(fallbackIds)
return layerList.value.filter(item => selectedSet.has(item.id))
}
function hasLockedSelectedLayers() {
return getSelectedLayers().some(layer => layer.config?.locked === true)
}
function getSelectedOrderItems(): LayerOrderItem[] {
if (selectedGroup.value?.groupId) {
// 图层面板里选中分组时,排序相关操作需要把它看成单一条目。
return [{
kind: 'group',
id: `group:${selectedGroup.value.groupId}`,
groupId: selectedGroup.value.groupId,
} satisfies LayerOrderItem]
}
const fallbackIds = selectedLayerIds.value.length ? selectedLayerIds.value : selectedLayerId.value ? [selectedLayerId.value] : []
if (!fallbackIds.length) {
return []
}
const items: LayerOrderItem[] = []
fallbackIds.forEach((id) => {
const layer = layerList.value.find(item => item.id === id)
if (!layer) {
return
}
items.push({
kind: 'component',
id,
groupId: getLayerGroupMeta(layer)?.groupId ?? undefined,
})
})
return items
}
function bringSelectedToFront() {
if (hasLockedSelectedLayers()) {
return
}
const orderItems = getSelectedOrderItems()
if (!orderItems.length) {
return
}
layerActions.moveLayerItemsToEdge(orderItems, 'front')
}
function sendSelectedToBack() {
if (hasLockedSelectedLayers()) {
return
}
const orderItems = getSelectedOrderItems()
if (!orderItems.length) {
return
}
layerActions.moveLayerItemsToEdge(orderItems, 'back')
}
function moveSelectedForward() {
if (hasLockedSelectedLayers()) {
return
}
const orderItems = getSelectedOrderItems()
if (!orderItems.length) {
return
}
layerActions.moveLayerItemsByStep(orderItems, 'forward')
}
function moveSelectedBackward() {
if (hasLockedSelectedLayers()) {
return
}
const orderItems = getSelectedOrderItems()
if (!orderItems.length) {
return
}
layerActions.moveLayerItemsByStep(orderItems, 'backward')
}
function canBringSelectedToFront() {
if (hasLockedSelectedLayers()) {
return false
}
const orderItems = getSelectedOrderItems()
return orderItems.length > 0 && layerActions.canMoveLayerItemsToEdge(orderItems, 'front')
}
function canSendSelectedToBack() {
if (hasLockedSelectedLayers()) {
return false
}
const orderItems = getSelectedOrderItems()
return orderItems.length > 0 && layerActions.canMoveLayerItemsToEdge(orderItems, 'back')
}
function canMoveSelectedForward() {
if (hasLockedSelectedLayers()) {
return false
}
const orderItems = getSelectedOrderItems()
return orderItems.length > 0 && layerActions.canMoveLayerItemsByStep(orderItems, 'forward')
}
function canMoveSelectedBackward() {
if (hasLockedSelectedLayers()) {
return false
}
const orderItems = getSelectedOrderItems()
return orderItems.length > 0 && layerActions.canMoveLayerItemsByStep(orderItems, 'backward')
}
function groupSelected(groupName?: string) {
if (!layerActions.canGroupLayers(selectedLayerIds.value)) {
return false
}
const groupId = layerActions.groupLayers(selectedLayerIds.value, groupName)
if (!groupId) {
return false
}
selectGroupById(groupId)
return true
}
async function ungroupSelected() {
const nextIds = selectedGroup.value?.layerIds ?? [...selectedLayerIds.value]
// 组内图层较多时弹出二次确认
if (nextIds.length >= 5) {
try {
const { ElMessageBox } = await import('element-plus')
await ElMessageBox.confirm(
`当前分组包含 ${nextIds.length} 个图层,确定解组吗?`,
'解组确认',
{ confirmButtonText: '解组', cancelButtonText: '取消', type: 'info' },
)
}
catch {
return false
}
}
const ungrouped = layerActions.ungroupLayers(nextIds)
if (!ungrouped) {
return false
}
setSelection(nextIds, nextIds[0] ?? null, { groupId: null })
return true
}
function replaceLayers(nextLayers: Layer[]) {
layerStore.replaceLayers(nextLayers)
}
function getDefaultItemSize(type = 'custom') {
const sizeMap: Record<string, { width: number, height: number }> = {
rect: { width: 160, height: 100 },
number: { width: 180, height: 72 },
text: { width: 180, height: 64 },
bar: { width: 48, height: 120 },
button: { width: 120, height: 40 },
pidController: { width: 160, height: 140 },
valveController: { width: 100, height: 60 },
canvasSwitcher: { width: 300, height: 40 },
}
const preset = sizeMap[type] || { width: 140, height: 60 }
const baseWidth = preset.width
const baseHeight = preset.height
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return { width: baseWidth, height: baseHeight }
}
return {
width: Math.min(baseWidth, canvasView.value.imageWidth),
height: Math.min(baseHeight, canvasView.value.imageHeight),
}
}
function placeLayerAtClientPoint(pointer: PointerPosition, type = 'custom') {
if (!pointer || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return false
}
if (pointer.canvasX < 0 || pointer.canvasY < 0 || pointer.canvasX > pointer.rect.width || pointer.canvasY > pointer.rect.height) {
return false
}
const size = getDefaultItemSize(type)
const imageX = pointer.imageX
const imageY = pointer.imageY
const clampedX = clamp(imageX, 0, Math.max(0, canvasView.value.imageWidth - size.width))
const clampedY = clamp(imageY, 0, Math.max(0, canvasView.value.imageHeight - size.height))
const snappedX = clamp(snapToGrid(clampedX, GRID_SIZE), 0, Math.max(0, canvasView.value.imageWidth - size.width))
const snappedY = clamp(snapToGrid(clampedY, GRID_SIZE), 0, Math.max(0, canvasView.value.imageHeight - size.height))
// 新增组件默认即为可见、可编辑状态,便于拖入后马上调整。
const newLayer: Layer = {
id: createLayerId(),
type: type as any,
x: snappedX,
y: snappedY,
width: size.width,
height: size.height,
config: {
locked: false,
visible: true,
fillColor: type === 'button' ? '#C8C8C8' : type === 'rect' ? '#B7B7B7' : undefined,
radius: type === 'rect' ? 0 : undefined,
fontSize: type === 'number' ? 24 : type === 'text' ? 18 : undefined,
fontWeight: type === 'number' ? 700 : 500,
content: type === 'text' ? '文本' : undefined,
decimals: type === 'number' ? 2 : undefined,
...(type === 'bar'
? {
value: 50,
direction: 'vertical',
min: 0,
max: 100,
showValue: true,
fillColor: '#000000',
foregroundColor: '#00ff00',
}
: {}),
...(type === 'button'
? {
label: '按钮',
buttonType: 'trigger',
confirmRequired: false,
}
: {}),
...(type === 'pidController'
? {
headerColor: '#0055ff',
labelColor: '#00cc00',
valueColor: '#ffffff',
bgColor: '#1a1a2e',
}
: {}),
...(type === 'valveController'
? {
bgColor: '#1a1a2e',
textColor: '#ffffff',
}
: {}),
...(type === 'canvasSwitcher'
? {
items: [],
activeColor: '#1677ff',
inactiveColor: '#f0f0f0',
activeTextColor: '#ffffff',
inactiveTextColor: '#333333',
}
: {}),
},
}
layerStore.appendLayers([newLayer])
setSelection([newLayer.id], newLayer.id)
return true
}
function placeTemplateAtPoint(pointer: PointerPosition, templateLayers: Layer[]) {
if (!pointer || !templateLayers.length || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return false
}
if (pointer.canvasX < 0 || pointer.canvasY < 0 || pointer.canvasX > pointer.rect.width || pointer.canvasY > pointer.rect.height) {
return false
}
// 计算模板图层的包围盒
const minX = Math.min(...templateLayers.map(l => l.x))
const minY = Math.min(...templateLayers.map(l => l.y))
// 将模板放到拖放位置,保持相对位置
const offsetX = pointer.imageX - minX
const offsetY = pointer.imageY - minY
const newLayers = templateLayers.map(l => ({
...JSON.parse(JSON.stringify(l)),
id: createLayerId(),
x: clamp(l.x + offsetX, 0, canvasView.value.imageWidth - l.width),
y: clamp(l.y + offsetY, 0, canvasView.value.imageHeight - l.height),
}))
layerStore.appendLayers(newLayers)
setSelection(newLayers.map(l => l.id), newLayers[0].id)
return true
}
return {
setSelection,
clearSelection,
selectLayerById,
selectLayersByIds,
selectGroupById,
removeSelectedLayers,
getSelectedLayers,
bringSelectedToFront,
sendSelectedToBack,
moveSelectedForward,
moveSelectedBackward,
canBringSelectedToFront,
canSendSelectedToBack,
canMoveSelectedForward,
canMoveSelectedBackward,
canGroupSelected: () => layerActions.canGroupLayers(selectedLayerIds.value),
groupSelected,
ungroupSelected,
replaceLayers,
placeLayerAtClientPoint,
placeTemplateAtPoint,
}
}

View File

@@ -0,0 +1,38 @@
import { storeToRefs } from 'pinia'
import { deleteCanvasApi } from '@/api'
import pinia, { useCanvasStore } from '@/stores'
type DeletePageResult
= | { ok: true }
| { ok: false, reason: 'not_found' | 'last_page' }
export function useCanvasPageActions() {
const canvasStore = useCanvasStore(pinia)
const { canvasList, canvasId } = storeToRefs(canvasStore)
async function deletePageById(pageId: string): Promise<DeletePageResult> {
const exists = canvasList.value.some(page => page.id === pageId)
if (!exists) {
return { ok: false, reason: 'not_found' }
}
if (canvasList.value.length <= 1) {
return { ok: false, reason: 'last_page' }
}
await deleteCanvasApi(pageId)
const nextList = await canvasStore.getCanvasList()
if (canvasId.value === pageId) {
// 当前页被删掉时自动切到新列表中的第一页,避免编辑器落在空状态。
const [nextCanvas] = nextList
canvasStore.setCanvasId(nextCanvas?.id || null)
}
return { ok: true }
}
return {
deletePageById,
}
}

View File

@@ -0,0 +1,112 @@
import { useDebounceFn } from '@vueuse/core'
import { ref } from 'vue'
import { updateCanvasBaseLayer, updateComponentsLayer } from '@/api'
import { useCanvasState } from '../context/state'
// Module-level shared state for save status
const saveStatus = ref<'saved' | 'saving' | 'idle' | 'error'>('idle')
const MAX_RETRIES = 3
const RETRY_BASE_DELAY = 500
async function withRetry<T>(fn: () => Promise<T>, retries = MAX_RETRIES): Promise<T> {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn()
}
catch (error) {
if (attempt === retries)
throw error
await new Promise(resolve => setTimeout(resolve, RETRY_BASE_DELAY * (attempt + 1)))
}
}
throw new Error('unreachable')
}
/** External components (e.g., StatusBar) can read save status via this */
export function useSaveStatus() {
return { saveStatus }
}
async function notifySaveError(context: string, error: unknown) {
try {
const { ElNotification } = await import('element-plus')
ElNotification.error({
title: '保存失败',
message: `${context}保存失败,请检查存储空间或刷新页面重试。`,
duration: 5000,
})
}
catch {
// 静默:通知组件不可用时不影响主流程
}
console.error(`[canvas] ${context} 保存失败`, error)
}
// 组件图层数据持久化相关逻辑
export function useLayerPersistence() {
const { canvasId, layerList, isHydratingCanvas, canvasView, activeCanvasThumbnail } = useCanvasState()
async function persistComponentsLayerNow() {
// 画布恢复阶段跳过保存,避免把服务端数据刚拉下来又原样写回。
if (!canvasId.value || isHydratingCanvas.value) {
return
}
const id = canvasId.value
saveStatus.value = 'saving'
try {
await withRetry(() => updateComponentsLayer({
id,
components: layerList.value,
}))
saveStatus.value = 'saved'
}
catch (error) {
saveStatus.value = 'error'
notifySaveError('组件图层', error)
}
}
// 组件变更统一走防抖保存,减少拖拽/缩放期间的高频请求。
const persistComponentsLayer = useDebounceFn(persistComponentsLayerNow, 300)
// 底图属性(宽高/宽高比锁定)自动保存
async function persistCanvasBaseLayerNow() {
if (!canvasId.value || isHydratingCanvas.value) {
return
}
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
const id = canvasId.value
saveStatus.value = 'saving'
try {
await withRetry(() => updateCanvasBaseLayer({
id,
width: Math.round(canvasView.value.imageWidth),
height: Math.round(canvasView.value.imageHeight),
thumbnail: activeCanvasThumbnail.value ? activeCanvasThumbnail.value : null,
lockAspectRatio: canvasView.value.backgroundLockAspectRatio,
backgroundColor: canvasView.value.background,
}))
saveStatus.value = 'saved'
}
catch (error) {
saveStatus.value = 'error'
notifySaveError('底图属性', error)
}
}
// 底图属性也走独立的防抖保存,避免和组件数据互相影响。
const persistCanvasBaseLayer = useDebounceFn(persistCanvasBaseLayerNow, 300)
return {
saveStatus,
persistComponentsLayerNow,
persistComponentsLayer,
persistCanvasBaseLayer,
}
}

View File

@@ -0,0 +1,161 @@
import type { ComputedRef } from 'vue'
import type { CanvasView, Layer } from '../types'
import { computed, ref, shallowRef } from 'vue'
export interface BaseLayerSnapshot {
imageWidth: number
imageHeight: number
background: string
backgroundLockAspectRatio: boolean
}
export interface HistoryEntry {
layerList: Layer[]
baseLayer: BaseLayerSnapshot
timestamp: number
label: string
}
export interface UseHistoryReturn {
canUndo: ComputedRef<boolean>
canRedo: ComputedRef<boolean>
undoLabel: ComputedRef<string>
redoLabel: ComputedRef<string>
pushState: (label: string, mergeContext?: { layerIds?: string[], fieldPath?: string }) => void
undo: () => void
redo: () => void
clear: () => void
isRestoring: ComputedRef<boolean>
}
const MAX_HISTORY = 50
const MERGE_WINDOW_MS = 300
interface MergeKey {
label: string
layerIds?: string[]
fieldPath?: string
}
function isSameMergeKey(a: MergeKey, b: MergeKey): boolean {
if (a.label !== b.label)
return false
if (a.fieldPath !== b.fieldPath)
return false
const aIds = a.layerIds?.join(',') ?? ''
const bIds = b.layerIds?.join(',') ?? ''
return aIds === bIds
}
export function useHistory(
getLayerList: () => Layer[],
getCanvasView: () => CanvasView,
onRestore: (entry: HistoryEntry) => void,
): UseHistoryReturn {
const undoStack = shallowRef<HistoryEntry[]>([])
const redoStack = shallowRef<HistoryEntry[]>([])
const _isRestoring = ref(false)
const lastMergeKey = ref<MergeKey>({ label: '' })
const lastPushTime = ref(0)
function captureSnapshot(label: string): HistoryEntry {
const view = getCanvasView()
return {
layerList: structuredClone(getLayerList()),
baseLayer: {
imageWidth: view.imageWidth,
imageHeight: view.imageHeight,
background: view.background,
backgroundLockAspectRatio: view.backgroundLockAspectRatio,
},
timestamp: Date.now(),
label,
}
}
function pushState(label: string, mergeContext?: { layerIds?: string[], fieldPath?: string }) {
if (_isRestoring.value)
return
const now = Date.now()
const currentKey: MergeKey = { label, layerIds: mergeContext?.layerIds, fieldPath: mergeContext?.fieldPath }
if (
isSameMergeKey(lastMergeKey.value, currentKey)
&& now - lastPushTime.value < MERGE_WINDOW_MS
&& undoStack.value.length > 0
) {
lastPushTime.value = now
return
}
const entry = captureSnapshot(label)
const next = [...undoStack.value, entry]
if (next.length > MAX_HISTORY) {
next.splice(0, next.length - MAX_HISTORY)
}
undoStack.value = next
redoStack.value = []
lastMergeKey.value = currentKey
lastPushTime.value = now
}
function undo() {
if (undoStack.value.length <= 1)
return
_isRestoring.value = true
try {
const stack = [...undoStack.value]
const current = stack.pop()!
redoStack.value = [...redoStack.value, current]
undoStack.value = stack
const target = stack.at(-1)
if (target) {
onRestore(target)
}
}
finally {
_isRestoring.value = false
}
lastMergeKey.value = { label: '' }
lastPushTime.value = 0
}
function redo() {
if (redoStack.value.length === 0)
return
_isRestoring.value = true
try {
const stack = [...redoStack.value]
const target = stack.pop()!
redoStack.value = stack
undoStack.value = [...undoStack.value, target]
onRestore(target)
}
finally {
_isRestoring.value = false
}
lastMergeKey.value = { label: '' }
lastPushTime.value = 0
}
function clear() {
undoStack.value = []
redoStack.value = []
lastMergeKey.value = { label: '' }
lastPushTime.value = 0
}
const canUndo = computed(() => undoStack.value.length > 1)
const canRedo = computed(() => redoStack.value.length > 0)
const undoLabel = computed(() => {
if (undoStack.value.length <= 1)
return ''
return undoStack.value.at(-1)?.label || ''
})
const redoLabel = computed(() => {
if (redoStack.value.length === 0)
return ''
return redoStack.value.at(-1)?.label || ''
})
const isRestoring = computed(() => _isRestoring.value)
return { canUndo, canRedo, undoLabel, redoLabel, pushState, undo, redo, clear, isRestoring }
}

View File

@@ -0,0 +1,111 @@
import type { LayerPositionUpdate } from './useLayerMutations'
import { useCanvasState } from '../context/state'
import { clamp, snapToGrid } from '../utils'
const GRID_SIZE = 10
type AlignType = 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom'
export function useLayerAlignment(deps: {
updateLayerPosition: (payload: LayerPositionUpdate) => void
updateLayerPositions: (updates: LayerPositionUpdate[]) => void
}) {
const { canvasView, layerList, selectedLayerId, selectedLayerIds } = useCanvasState()
function getSelectedLayers() {
const fallbackIds = selectedLayerIds.value.length ? selectedLayerIds.value : selectedLayerId.value ? [selectedLayerId.value] : []
const selectedSet = new Set(fallbackIds)
return layerList.value.filter(layer => selectedSet.has(layer.id))
}
function alignSelected(type: AlignType) {
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
const layers = getSelectedLayers()
if (!layers.length) {
return
}
if (layers.length === 1) {
// 单个图层时参考对象是整个底图,多图层时参考对象是选区包围盒。
const layer = layers[0]
let newX = layer.x
let newY = layer.y
switch (type) {
case 'left':
newX = 0
break
case 'center':
newX = (canvasView.value.imageWidth - layer.width) / 2
break
case 'right':
newX = canvasView.value.imageWidth - layer.width
break
case 'top':
newY = 0
break
case 'middle':
newY = (canvasView.value.imageHeight - layer.height) / 2
break
case 'bottom':
newY = canvasView.value.imageHeight - layer.height
break
}
deps.updateLayerPosition({
id: layer.id,
nextX: clamp(snapToGrid(newX, GRID_SIZE), 0, Math.max(0, canvasView.value.imageWidth - layer.width)),
nextY: clamp(snapToGrid(newY, GRID_SIZE), 0, Math.max(0, canvasView.value.imageHeight - layer.height)),
})
return
}
const left = Math.min(...layers.map(layer => layer.x))
const top = Math.min(...layers.map(layer => layer.y))
const right = Math.max(...layers.map(layer => layer.x + layer.width))
const bottom = Math.max(...layers.map(layer => layer.y + layer.height))
const centerX = (left + right) / 2
const centerY = (top + bottom) / 2
const updates: LayerPositionUpdate[] = layers.map((layer) => {
let nextX = layer.x
let nextY = layer.y
switch (type) {
case 'left':
nextX = left
break
case 'center':
nextX = centerX - layer.width / 2
break
case 'right':
nextX = right - layer.width
break
case 'top':
nextY = top
break
case 'middle':
nextY = centerY - layer.height / 2
break
case 'bottom':
nextY = bottom - layer.height
break
}
return {
id: layer.id,
nextX: clamp(snapToGrid(nextX, GRID_SIZE), 0, Math.max(0, canvasView.value.imageWidth - layer.width)),
nextY: clamp(snapToGrid(nextY, GRID_SIZE), 0, Math.max(0, canvasView.value.imageHeight - layer.height)),
}
})
deps.updateLayerPositions(updates)
}
return {
alignSelected,
}
}

View File

@@ -0,0 +1,227 @@
import { computed, watch } from 'vue'
import { useComponentTemplates } from '@/composables/useComponentTemplates'
import { useOperationLog } from '@/composables/useOperationLog'
import { useCanvasInject } from '../context'
import { useCanvasState } from '../context/state'
import { listenEditorEmitter, setEditorEmitter } from '../emitter'
import { useLayerActions, useLayerSelection } from './layer'
import { useLayerPersistence } from './persistence'
import { useHistory } from './useHistory'
import { useLayerAlignment } from './useLayerAlignment'
import { useLayerMutations } from './useLayerMutations'
import { useLayerSelectionSync } from './useLayerSelectionSync'
import { useSelectionBox } from './useSelectionBox'
export function useLayerController() {
const { layerList, selectedLayerId, selectedLayerIds, isHydratingCanvas, canvasView } = useCanvasState()
const { log: logOperation } = useOperationLog()
const {
clearSelection,
setSelection,
selectLayerById,
selectLayersByIds,
bringSelectedToFront: _bringSelectedToFront,
sendSelectedToBack: _sendSelectedToBack,
groupSelected: _groupSelected,
ungroupSelected: _ungroupSelected,
placeLayerAtClientPoint,
placeTemplateAtPoint,
replaceLayers,
} = useLayerSelection()
const { toggleLayerLock, toggleLayerVisible } = useLayerActions()
const {
updateLayerPosition,
updateLayerPositions,
updateLayerRect,
updateLayerRects,
updateLayerField,
updateLayerFields,
} = useLayerMutations()
const { onBackgroundPointerDown } = useSelectionBox()
const { alignSelected } = useLayerAlignment({
updateLayerPosition,
updateLayerPositions,
})
useLayerSelectionSync()
const selectedCount = computed(() => selectedLayerIds.value.length)
const { persistComponentsLayer, persistCanvasBaseLayer } = useLayerPersistence()
watch(
layerList,
() => {
// 组件拖拽、缩放、删除等变更后自动落盘。
persistComponentsLayer()
},
{ deep: true },
)
const history = useHistory(
() => layerList.value,
() => canvasView.value,
(entry) => {
// 恢复图层
replaceLayers(entry.layerList)
// 恢复底图属性
const cv = canvasView.value
cv.imageWidth = entry.baseLayer.imageWidth
cv.imageHeight = entry.baseLayer.imageHeight
cv.background = entry.baseLayer.background
cv.backgroundLockAspectRatio = entry.baseLayer.backgroundLockAspectRatio
persistCanvasBaseLayer()
},
)
const { getPointerImagePosition } = useCanvasInject()
const { getTemplate } = useComponentTemplates()
function onCanvasDrop({ event, type }: { event: DragEvent, type: string }) {
// 模板拖入
if (type === '__template__') {
let templateId = ''
try {
const data = JSON.parse(event.dataTransfer?.getData('application/json') ?? '{}')
templateId = data.templateId ?? ''
}
catch { /* ignore */ }
const template = templateId ? getTemplate(templateId) : null
if (template) {
const pointer = getPointerImagePosition(event.clientX, event.clientY)
const placed = placeTemplateAtPoint(pointer, template.layers)
if (placed) {
history.pushState('从模板添加')
logOperation('create', template.name, '从模板添加')
}
}
return
}
const pointer = getPointerImagePosition(event.clientX, event.clientY)
const placed = placeLayerAtClientPoint(pointer, type)
if (placed) {
history.pushState('添加图层')
logOperation('create', type, '添加图层')
}
}
listenEditorEmitter('canvasStage:drop', onCanvasDrop)
function onLayerClick(payload: { event: MouseEvent, id: string }) {
const { event, id } = payload
const additive = event.metaKey || event.ctrlKey || event.shiftKey
if (!additive) {
setSelection([id], id, { groupId: null })
return
}
const exists = selectedLayerIds.value.includes(id)
if (exists) {
// 加选模式下再次点击已选图层,等价于把它从当前选区移除。
const next = selectedLayerIds.value.filter(entry => entry !== id)
setSelection(next, selectedLayerId.value === id ? next[0] ?? null : selectedLayerId.value)
return
}
setSelection([...selectedLayerIds.value, id], id)
}
function onCanvasLayerClick(payload: { event: MouseEvent, id: string }) {
onLayerClick(payload)
}
listenEditorEmitter('canvasStage:layerClick', onCanvasLayerClick)
function toggleLayerLockState(id: string, locked?: boolean) {
toggleLayerLock(id, locked)
}
function toggleLayerVisibleState(id: string, visible?: boolean) {
toggleLayerVisible(id, visible)
}
watch(isHydratingCanvas, (hydrating, prevHydrating) => {
if (prevHydrating && !hydrating) {
history.pushState('初始状态')
}
})
// 监听来自 header 按钮和键盘快捷键的撤销/重做事件
listenEditorEmitter('header:undo', () => {
history.undo()
logOperation('undo', '', '撤销操作')
})
listenEditorEmitter('header:redo', () => {
history.redo()
logOperation('redo', '', '重做操作')
})
listenEditorEmitter('history:push', ({ label }) => {
history.pushState(label)
logOperation('edit', '', label)
})
// 向 header 广播历史状态变化
watch([history.canUndo, history.canRedo, history.undoLabel, history.redoLabel], () => {
setEditorEmitter('history:stateChange', {
canUndo: history.canUndo.value,
canRedo: history.canRedo.value,
undoLabel: history.undoLabel.value,
redoLabel: history.redoLabel.value,
})
})
// 包装图层操作,在操作成功后自动记录历史
function bringSelectedToFront() {
_bringSelectedToFront()
history.pushState('调整层级')
logOperation('edit', selectedLayerIds.value.join(','), '前置图层')
}
function sendSelectedToBack() {
_sendSelectedToBack()
history.pushState('调整层级')
logOperation('edit', selectedLayerIds.value.join(','), '后置图层')
}
function groupSelected(groupName?: string) {
const result = _groupSelected(groupName)
if (result) {
history.pushState('创建分组')
logOperation('group', groupName ?? '', '创建分组')
}
return result
}
async function ungroupSelected() {
const result = await _ungroupSelected()
if (result) {
history.pushState('解除分组')
logOperation('group', '', '解除分组')
}
return result
}
return {
layerList,
selectedLayerIds,
selectedLayerId,
selectedCount,
onCanvasDrop,
onBackgroundPointerDown,
onLayerClick,
selectLayerById,
selectLayersByIds,
clearSelection,
updateLayerPosition,
updateLayerPositions,
updateLayerRect,
updateLayerRects,
alignSelected,
bringSelectedToFront,
sendSelectedToBack,
toggleLayerLockState,
toggleLayerVisibleState,
updateLayerField,
updateLayerFields,
groupSelected,
ungroupSelected,
history,
}
}

View File

@@ -0,0 +1,63 @@
import pinia, { useLayerStore } from '@/stores'
import { listenEditorEmitter } from '../emitter'
export interface LayerPositionUpdate {
id: string
nextX: number
nextY: number
}
export interface LayerRectUpdate extends LayerPositionUpdate {
nextWidth: number
nextHeight: number
options?: { ignoreLock?: boolean }
}
export function useLayerMutations() {
const layerStore = useLayerStore(pinia)
function updateLayerPosition(payload: LayerPositionUpdate) {
layerStore.updateLayerPosition(payload)
}
function updateLayerPositions(updates: LayerPositionUpdate[]) {
layerStore.updateLayerPositions(updates)
}
function updateLayerRect(payload: LayerRectUpdate) {
layerStore.updateLayerRect(payload)
}
function updateLayerRects(updates: LayerRectUpdate[]) {
layerStore.updateLayerRects(updates)
}
function updateLayerField(id: string, field: string, value: unknown, options?: { ignoreLock?: boolean }) {
layerStore.updateLayerField(id, field, value, options)
}
function updateLayerFields(ids: string[], field: string, value: unknown, options?: { ignoreLock?: boolean }) {
layerStore.updateLayerFields(ids, field, value, options)
}
// 将舞台、图层面板、属性面板的更新请求统一桥接到 layer store。
listenEditorEmitter('layer:updatePosition', updateLayerPosition)
listenEditorEmitter('layer:updatePositions', updateLayerPositions)
listenEditorEmitter('layer:updateRect', updateLayerRect)
listenEditorEmitter('layer:updateRects', updateLayerRects)
listenEditorEmitter('layer:updateField', ({ id, field, value, options }) => {
updateLayerField(id, field, value, options)
})
listenEditorEmitter('layer:updateFields', ({ ids, field, value, options }) => {
updateLayerFields(ids, field, value, options)
})
return {
updateLayerPosition,
updateLayerPositions,
updateLayerRect,
updateLayerRects,
updateLayerField,
updateLayerFields,
}
}

View File

@@ -0,0 +1,29 @@
import type { CSSProperties } from 'vue'
import type { Layer } from '../types'
import { useCanvasState } from '../context/state'
export function useLayerRender() {
const { canvasView } = useCanvasState()
function getLayerStyle(layer: Layer): CSSProperties {
// 图层 DOM 始终按当前视口偏移和缩放换算,和底图保持同一坐标系。
const left = canvasView.value.offsetX + layer.x * canvasView.value.scale
const top = canvasView.value.offsetY + layer.y * canvasView.value.scale
const width = layer.width * canvasView.value.scale
const height = layer.height * canvasView.value.scale
const rotate = layer.config?.rotate ?? 0
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
opacity: layer.config?.visible === false ? 0.35 : 1,
cursor: layer.config?.locked ? 'not-allowed' : 'pointer',
...(rotate ? { transform: `rotate(${rotate}deg)`, transformOrigin: 'center center' } : {}),
}
}
return {
getLayerStyle,
}
}

View File

@@ -0,0 +1,122 @@
import { watch } from 'vue'
import pinia, { useLayerStore } from '@/stores'
import { useCanvasState } from '../context/state'
import { setEditorEmitter } from '../emitter'
import { useLayerSelection } from './layer'
export function useLayerSelectionSync() {
const { layerList, selectedLayerId, selectedLayerIds, selectedGroupId, isBaseLayerActive } = useCanvasState()
const layerStore = useLayerStore(pinia)
const { selectLayerById } = useLayerSelection()
watch(
() => selectedLayerIds.value,
(ids) => {
// 多选数组是主要真值,主选中项和属性面板展示都从它推导。
if (!ids.length) {
layerStore.setLayerId('')
if (isBaseLayerActive.value) {
setEditorEmitter('propertyPanel:openPanel')
}
else {
setEditorEmitter('propertyPanel:closePanel')
}
return
}
const primaryId = ids.includes(selectedLayerId.value) ? selectedLayerId.value : ids.at(-1)
if (primaryId && primaryId !== selectedLayerId.value) {
layerStore.setLayerId(primaryId)
return
}
if (selectedGroupId.value) {
setEditorEmitter('propertyPanel:openPanel')
return
}
if (ids.length === 1) {
setEditorEmitter('propertyPanel:openPanel')
return
}
setEditorEmitter('propertyPanel:closePanel')
},
{ deep: false, flush: 'sync' },
)
watch(
() => selectedLayerId.value,
(nextLayerId) => {
// 外部如果只改了主选中项,这里会把多选数组补齐回一致状态。
if (!nextLayerId) {
return
}
if (selectedGroupId.value && selectedLayerIds.value.includes(nextLayerId)) {
setEditorEmitter('propertyPanel:openPanel')
return
}
if (selectedLayerIds.value.length > 1 && selectedLayerIds.value.includes(nextLayerId)) {
setEditorEmitter('propertyPanel:closePanel')
return
}
const selected = selectLayerById(nextLayerId)
if (selected) {
setEditorEmitter('propertyPanel:openPanel')
return
}
layerStore.setLayerId('')
},
{ flush: 'sync' },
)
watch(
() => isBaseLayerActive.value,
(active) => {
if (selectedLayerIds.value.length) {
return
}
if (active) {
setEditorEmitter('propertyPanel:openPanel')
}
else {
setEditorEmitter('propertyPanel:closePanel')
}
},
{ flush: 'sync' },
)
watch(
() => selectedGroupId.value,
(groupId) => {
if (groupId) {
setEditorEmitter('propertyPanel:openPanel')
return
}
if (selectedLayerIds.value.length > 1) {
setEditorEmitter('propertyPanel:closePanel')
}
},
{ flush: 'sync' },
)
watch(
layerList,
() => {
// 图层被删除后兜底清空失效选中态,避免属性面板指向幽灵节点。
if (!selectedLayerId.value) {
return
}
const exists = layerList.value.some(item => item.id === selectedLayerId.value)
if (!exists) {
layerStore.setLayerId('')
if (!selectedLayerIds.value.length) {
if (isBaseLayerActive.value) {
setEditorEmitter('propertyPanel:openPanel')
}
else {
setEditorEmitter('propertyPanel:closePanel')
}
}
}
},
{ deep: true },
)
}

View File

@@ -0,0 +1,152 @@
import { onBeforeUnmount } from 'vue'
import { useCanvasInject } from '../context'
import { useCanvasState } from '../context/state'
import { listenEditorEmitter, setEditorEmitter } from '../emitter'
import { buildLayerGroups } from '../grouping'
import { normalizeRect, rectIntersects } from '../utils'
import { useLayerSelection } from './layer'
const BOX_SELECT_THRESHOLD = 4
export function useSelectionBox() {
const { canvasView, layerList, selectedLayerId, selectedLayerIds, isBaseLayerActive, boxSelect } = useCanvasState()
const { clearSelection, selectGroupById, setSelection } = useLayerSelection()
const { getPointerImagePosition } = useCanvasInject()
const pointerTracker = {
startClientX: 0,
startClientY: 0,
currentClientX: 0,
currentClientY: 0,
hitGroupId: '',
panelSuspended: false,
}
function isPointInsideRect(point: { imageX: number, imageY: number }, rect: { x: number, y: number, width: number, height: number }) {
return point.imageX >= rect.x
&& point.imageY >= rect.y
&& point.imageX <= rect.x + rect.width
&& point.imageY <= rect.y + rect.height
}
function isPointMoved() {
return Math.abs(pointerTracker.currentClientX - pointerTracker.startClientX) > BOX_SELECT_THRESHOLD
|| Math.abs(pointerTracker.currentClientY - pointerTracker.startClientY) > BOX_SELECT_THRESHOLD
}
function stopBoxSelect() {
// 统一从这里收尾,确保事件和属性面板状态都能被正确恢复。
boxSelect.value.active = false
boxSelect.value.moved = false
pointerTracker.hitGroupId = ''
if (pointerTracker.panelSuspended) {
pointerTracker.panelSuspended = false
setEditorEmitter('propertyPanel:resume')
}
window.removeEventListener('pointermove', onBoxSelectMove)
window.removeEventListener('pointerup', onBoxSelectUp)
}
function onBoxSelectMove(event: PointerEvent) {
if (!boxSelect.value.active) {
return
}
const pointer = getPointerImagePosition(event.clientX, event.clientY)
if (!pointer) {
return
}
pointerTracker.currentClientX = event.clientX
pointerTracker.currentClientY = event.clientY
boxSelect.value.currentX = pointer.imageX
boxSelect.value.currentY = pointer.imageY
boxSelect.value.moved = isPointMoved()
if (boxSelect.value.moved && !pointerTracker.panelSuspended) {
pointerTracker.panelSuspended = true
setEditorEmitter('propertyPanel:suspend')
}
}
function onBoxSelectUp() {
if (!boxSelect.value.active) {
return
}
if (!boxSelect.value.moved) {
if (pointerTracker.hitGroupId) {
selectGroupById(pointerTracker.hitGroupId)
stopBoxSelect()
return
}
if (!boxSelect.value.additive && !isBaseLayerActive.value) {
clearSelection()
}
stopBoxSelect()
return
}
const selectionRect = normalizeRect(boxSelect.value.startX, boxSelect.value.startY, boxSelect.value.currentX, boxSelect.value.currentY)
// 框选采用“矩形相交”策略,而不是完全包裹,降低选中门槛。
const nextIds = layerList.value
.filter(item => rectIntersects(selectionRect, item))
.map(item => item.id)
if (boxSelect.value.additive) {
setSelection([...selectedLayerIds.value, ...nextIds], nextIds[0] ?? selectedLayerId.value)
}
else {
setSelection(nextIds, nextIds[0] ?? null)
}
stopBoxSelect()
}
function onBackgroundPointerDown(event: PointerEvent) {
if (event.button !== 0) {
return false
}
const pointer = getPointerImagePosition(event.clientX, event.clientY)
if (!pointer) {
return false
}
if (
pointer.imageX < 0
|| pointer.imageY < 0
|| pointer.imageX > canvasView.value.imageWidth
|| pointer.imageY > canvasView.value.imageHeight
) {
return false
}
event.preventDefault()
boxSelect.value.active = true
boxSelect.value.additive = event.metaKey || event.ctrlKey || event.shiftKey
boxSelect.value.moved = false
boxSelect.value.startX = pointer.imageX
boxSelect.value.startY = pointer.imageY
boxSelect.value.currentX = pointer.imageX
boxSelect.value.currentY = pointer.imageY
pointerTracker.startClientX = event.clientX
pointerTracker.startClientY = event.clientY
pointerTracker.currentClientX = event.clientX
pointerTracker.currentClientY = event.clientY
// 点按时顺手命中分组,允许“单击组选中,拖拽时框选”的双重语义。
const hitGroup = buildLayerGroups(layerList.value)
.find(group => isPointInsideRect(pointer, group))
pointerTracker.hitGroupId = hitGroup?.groupId ?? ''
window.addEventListener('pointermove', onBoxSelectMove)
window.addEventListener('pointerup', onBoxSelectUp)
return true
}
listenEditorEmitter('selection:backgroundPointerDown', onBackgroundPointerDown)
onBeforeUnmount(() => {
stopBoxSelect()
})
return {
onBackgroundPointerDown,
stopBoxSelect,
}
}

View File

@@ -0,0 +1,78 @@
import type { CanvasStageExpose } from '../types'
import { inject, provide, shallowRef } from 'vue'
import { unwrapElement } from '../utils'
import { useCanvasState } from './state'
export type CanvasCursorMode = 'default' | 'move'
export type PointerPosition = {
imageX: number
imageY: number
rect: DOMRect
canvasX: number
canvasY: number
} | null
const CanvasProviderKey = Symbol('canvas-provider')
export function useCanvasProvider() {
// 编辑器主区域的 ref包含画布和背景等元素。用于事件监听时判断事件是否发生在编辑器主区域。
const editorRef = shallowRef<HTMLElement | null>(null)
// 画布 ref包含画布元素和相关方法。用于在属性面板等子组件中访问和操作画布。
const stageRef = shallowRef<CanvasStageExpose | null>(null)
function getWrapperElement() {
return unwrapElement(stageRef.value?.wrapperRef)
}
function getCanvasElement() {
return unwrapElement(stageRef.value?.canvasRef) as HTMLCanvasElement
}
const { canvasView } = useCanvasState()
function getPointerImagePosition(clientX: number, clientY: number): PointerPosition {
const canvas = getCanvasElement()
if (!canvas || canvasView.value.scale <= 0) {
return null
}
const rect = canvas.getBoundingClientRect()
const canvasX = clientX - rect.left
const canvasY = clientY - rect.top
// 同时返回屏幕坐标和底图坐标,供拖拽放置、框选、缩放复用。
return {
imageX: (canvasX - canvasView.value.offsetX) / canvasView.value.scale,
imageY: (canvasY - canvasView.value.offsetY) / canvasView.value.scale,
rect,
canvasX,
canvasY,
}
}
// 属性面板 ref包含属性面板元素和相关方法。用于在画布等父组件中访问和操作属性面板。
const propertyPanelRef = shallowRef<HTMLElement | null>(null)
// 左侧边栏 ref包含图层、组件等编辑器侧边面板。
const sidebarRef = shallowRef<HTMLElement | null>(null)
const state = {
editorRef,
stageRef,
propertyPanelRef,
sidebarRef,
getWrapperElement,
getCanvasElement,
getPointerImagePosition,
}
provide(CanvasProviderKey, state)
return state
}
export type CanvasProviderState = ReturnType<typeof useCanvasProvider>
export function useCanvasInject() {
return inject<CanvasProviderState>(CanvasProviderKey)!
}

View File

@@ -0,0 +1,26 @@
import type { InjectionKey, Ref } from 'vue'
import { inject, provide } from 'vue'
// ── 编辑器模式 ──
export type EditorMode = 'editor' | 'runtime'
export const EditorModeKey: InjectionKey<EditorMode> = Symbol('editor-mode')
export function provideEditorMode(mode: EditorMode) {
provide(EditorModeKey, mode)
}
export function useEditorModeInject(): EditorMode {
return inject(EditorModeKey, 'editor')
}
// ── 预览缩放 ──
export const PreviewScaleKey: InjectionKey<Ref<number>> = Symbol('preview-scale')
export function providePreviewScale(scale: Ref<number>) {
provide(PreviewScaleKey, scale)
}
export function usePreviewScaleInject(): Ref<number> | undefined {
return inject(PreviewScaleKey, undefined)
}

View File

@@ -0,0 +1,6 @@
export * from './canvas'
export * from './editor-mode'
export * from './page'
export * from './runtime'
export * from './state'
export * from './ui'

View File

@@ -0,0 +1,54 @@
import { storeToRefs } from 'pinia'
import { inject, provide, ref } from 'vue'
import pinia, { useCanvasStore } from '@/stores'
const CanvasPageKey = Symbol('page')
export function useCanvasPageProvider() {
const canvasStore = useCanvasStore(pinia)
const { canvasList } = storeToRefs(canvasStore)
function findPageById(pageId: string) {
return canvasList.value.find(page => page.id === pageId) || null
}
// 画布管理弹窗(新增/重命名)
const showPageDialog = ref(false)
function changePageDialogVisible(visible: boolean) {
showPageDialog.value = visible
}
const editingPageId = ref<string | null>(null)
// 打开“新增画布”弹窗
function openCreatePageDialog() {
editingPageId.value = null
showPageDialog.value = true
}
// 打开“重命名画布”弹窗
function openRenamePageDialog(pageId: string) {
const page = findPageById(pageId)
if (!page) {
return
}
editingPageId.value = pageId
showPageDialog.value = true
}
const state = {
showPageDialog,
editingPageId,
changePageDialogVisible,
openRenamePageDialog,
openCreatePageDialog,
}
provide(CanvasPageKey, state)
return state
}
export function useCanvasPageInject() {
return inject<ReturnType<typeof useCanvasPageProvider>>(CanvasPageKey)!
}

View File

@@ -0,0 +1,372 @@
import type { VariableValueType } from '@cslab-dcs/schema'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
import type { DynamicProjectProperty } from '@/api'
import { storeToRefs } from 'pinia'
import { computed, inject, provide, ref, shallowRef, watch } from 'vue'
import { getDynamicProjectModulePropsApi, getDynamicProjectModulesApi } from '@/api'
import pinia, { useProjectStore } from '@/stores'
import { useCanvasState } from './state'
export interface CanvasVariableOption {
label: string
value: string
children?: CanvasVariableOption[]
}
export interface CanvasRuntimeVariable {
path: string
moduleLabel: string
moduleName: string
moduleDescribe?: string
propLabel: string
propName: string
describe?: string
unit?: string
type: VariableValueType
defaultValue?: unknown
value?: unknown
}
export interface CanvasRuntimeState {
isLoadingVariables: Ref<boolean>
variableLoadError: Ref<string>
variableOptions: ComputedRef<CanvasVariableOption[]>
variableMap: ComputedRef<Record<string, CanvasRuntimeVariable>>
backgroundSampleColor: ComputedRef<string>
autoTextColor: ComputedRef<string>
ensureVariableCatalogLoaded: (force?: boolean) => Promise<void>
refreshVariableCatalog: () => Promise<void>
setBackgroundAverageColor: (value: string | null) => void
}
export const CanvasRuntimeKey: InjectionKey<CanvasRuntimeState> = Symbol('canvas-runtime')
const HEX_COLOR_RE = /^#(?:[\dA-F]{3}|[\dA-F]{6})$/i
// 统一只接受十六进制颜色,便于后续自动推导文字颜色。
function isHexColor(value: string) {
return HEX_COLOR_RE.test(value.trim())
}
function normalizeColor(value: string | null | undefined) {
if (!value) {
return null
}
const normalized = value.trim()
return isHexColor(normalized) ? normalized : null
}
export function getReadableTextColor(color: string) {
const normalized = normalizeColor(color) || '#FFFFFF'
const hex = normalized.slice(1)
const expanded = hex.length === 3
? hex.split('').map(char => `${char}${char}`).join('')
: hex
const red = Number.parseInt(expanded.slice(0, 2), 16)
const green = Number.parseInt(expanded.slice(2, 4), 16)
const blue = Number.parseInt(expanded.slice(4, 6), 16)
const brightness = (red * 299 + green * 587 + blue * 114) / 1000
return brightness >= 160 ? '#111827' : '#FFFFFF'
}
function inferVariableType(property: DynamicProjectProperty): VariableValueType {
// classify: 0-整数 1-浮点 2-字符串 3-列表 4-枚举 5-布尔 6-组分 7-已知变量
switch (property.classify) {
case '5':
return 'digital'
case '2':
return 'string'
case '4':
return 'enum'
default:
return 'analog'
}
}
function hasLayerBindingValue(binding: string | { value?: string } | Array<string | { value?: string }> | undefined): boolean {
if (!binding) {
return false
}
if (Array.isArray(binding)) {
return binding.some(item => hasLayerBindingValue(item))
}
if (typeof binding === 'string') {
return Boolean(binding.trim())
}
return typeof binding.value === 'string' && Boolean(binding.value.trim())
}
export function useCanvasRuntimeProvider() {
const projectStore = useProjectStore(pinia)
const { projectId, projectInfo } = storeToRefs(projectStore)
const { canvasInfo, canvasView, layerList } = useCanvasState()
// 变量目录来自基础项目接口,和当前画布上已绑定的变量值分开维护。
const runtimeVariables = shallowRef<CanvasRuntimeVariable[]>([])
const isLoadingVariables = ref(false)
const variableLoadError = ref('')
const backgroundAverageColor = shallowRef<string | null>(null)
const loadedBaseProjectId = ref('')
// 通过递增 token 丢弃过期请求,避免快速切画布时旧响应覆盖新状态。
let requestToken = 0
function setBackgroundAverageColor(value: string | null) {
backgroundAverageColor.value = normalizeColor(value)
}
const canvasVarMap = computed(() => {
const map = new Map<string, unknown>()
for (const variable of canvasInfo.value?.vars || []) {
const name = variable.name?.trim()
if (!name) {
continue
}
if (variable.defaultValue !== undefined) {
map.set(name, variable.defaultValue)
}
if (variable.sourceId?.trim()) {
map.set(variable.sourceId.trim(), variable.defaultValue)
}
}
return map
})
const variableMap = computed<Record<string, CanvasRuntimeVariable>>(() => {
// 运行时变量以接口目录为主,画布上已有的默认值优先覆盖展示值。
return runtimeVariables.value.reduce<Record<string, CanvasRuntimeVariable>>((result, item) => {
const canvasValue = canvasVarMap.value.get(item.path) ?? canvasVarMap.value.get(item.propLabel)
result[item.path] = {
...item,
value: canvasValue !== undefined ? canvasValue : item.defaultValue,
}
return result
}, {})
})
const variableOptions = computed<CanvasVariableOption[]>(() => {
// 变量选择器按照模块分组,便于属性面板直接构造级联选项。
const groups = new Map<string, CanvasVariableOption>()
for (const item of runtimeVariables.value) {
const group = groups.get(item.moduleName) || {
label: `${item.moduleName}(${item.moduleLabel})`,
value: item.moduleName,
children: [],
}
group.children!.push({
label: item.propName,
value: item.path,
})
groups.set(item.moduleName, group)
}
return Array.from(groups.values(), (group) => {
group.children = group.children?.sort((left, right) => left.label.localeCompare(right.label))
return group
})
})
const backgroundSampleColor = computed(() => {
return backgroundAverageColor.value || normalizeColor(canvasView.value.background) || '#FFFFFF'
})
const autoTextColor = computed(() => getReadableTextColor(backgroundSampleColor.value))
const hasBoundLayers = computed(() => {
// 只有确实存在绑定的图层时才去拉变量目录,避免无意义请求。
return layerList.value.some(layer => Object.values(layer.bindings || {}).some(binding => hasLayerBindingValue(binding)))
})
async function resolveBaseProjectId() {
if (!projectId.value) {
console.warn('[canvas-runtime] resolveBaseProjectId: projectId 为空,无法加载变量目录')
return ''
}
const cachedBaseProjectId = projectInfo.value?.base_pro?.trim()
if (cachedBaseProjectId) {
return cachedBaseProjectId
}
await projectStore.getProjectInfo()
const resolved = projectInfo.value?.base_pro?.trim() || ''
if (!resolved) {
console.warn('[canvas-runtime] resolveBaseProjectId: 项目 base_pro 为空', {
projectId: projectId.value,
projectInfo: projectInfo.value,
})
}
return resolved
}
function resetVariableCatalog() {
requestToken += 1
runtimeVariables.value = []
isLoadingVariables.value = false
loadedBaseProjectId.value = ''
}
async function refreshVariableCatalog() {
const baseProjectId = await resolveBaseProjectId()
if (!baseProjectId) {
resetVariableCatalog()
return
}
if (isLoadingVariables.value && loadedBaseProjectId.value === baseProjectId) {
return
}
const currentToken = ++requestToken
isLoadingVariables.value = true
variableLoadError.value = ''
try {
// 先取模块,再并行拉每个模块下的属性定义,最后拍平成路径字典。
console.log('[canvas-runtime] 正在加载变量目录', { baseProjectId })
const modules = await getDynamicProjectModulesApi(baseProjectId)
console.log('[canvas-runtime] 模块列表', { count: modules.length, modules })
if (!modules.length) {
console.warn('[canvas-runtime] module list is empty', { baseProjectId })
}
// 同 label 的模块属性定义完全一致,按 label 去重请求,避免重复调用。
const labelToPk = new Map<string, string>()
for (const m of modules) {
if (!labelToPk.has(m.label)) {
labelToPk.set(m.label, m.module_pk)
}
}
const propsByLabel = new Map<string, DynamicProjectProperty[]>()
await Promise.all(
Array.from(labelToPk.entries(), async ([label, modulePk]) => {
try {
const props = await getDynamicProjectModulePropsApi(baseProjectId, modulePk)
if (!props.length) {
console.warn('[canvas-runtime] module props are empty', {
baseProjectId,
moduleLabel: label,
modulePk,
})
}
propsByLabel.set(label, props)
}
catch (error) {
console.error('[canvas-runtime] failed to load module props', label, error)
propsByLabel.set(label, [])
}
}),
)
const propGroups = modules.map(module => ({
module,
props: propsByLabel.get(module.label) || [],
}))
if (currentToken !== requestToken) {
return
}
// 按 path 去重:模块列表或属性列表可能包含重复项,只保留首次出现的
const seenPaths = new Set<string>()
runtimeVariables.value = propGroups.flatMap(({ module, props }) => {
return props.reduce<CanvasRuntimeVariable[]>((acc, prop) => {
const path = `${module.name}.${prop.name}`
if (!seenPaths.has(path)) {
seenPaths.add(path)
acc.push({
path,
moduleLabel: module.label,
moduleName: module.name,
moduleDescribe: module.describe,
propLabel: prop.name,
propName: prop.displayLabel,
describe: prop.describe,
type: inferVariableType(prop),
defaultValue: prop.defaultValue,
})
}
return acc
}, [])
})
if (!runtimeVariables.value.length) {
variableLoadError.value = modules.length
? `已加载 ${modules.length} 个模块,但未获取到属性数据`
: '模块列表为空,请检查基础项目配置'
console.warn('[canvas-runtime] runtime variable catalog is empty after normalization', {
baseProjectId,
moduleCount: modules.length,
propGroupCount: propGroups.length,
})
}
loadedBaseProjectId.value = baseProjectId
}
catch (error) {
if (currentToken === requestToken) {
runtimeVariables.value = []
loadedBaseProjectId.value = ''
variableLoadError.value = `加载变量失败: ${error instanceof Error ? error.message : '未知错误'}`
}
console.error('[canvas-runtime] failed to load variable catalog', error)
}
finally {
if (currentToken === requestToken) {
isLoadingVariables.value = false
}
}
}
async function ensureVariableCatalogLoaded(force = false) {
const baseProjectId = await resolveBaseProjectId()
if (!baseProjectId) {
resetVariableCatalog()
variableLoadError.value = '项目未关联动态项目,无法加载变量目录'
return
}
if (!force && loadedBaseProjectId.value === baseProjectId) {
return
}
await refreshVariableCatalog()
}
watch(
() => projectId.value,
(nextProjectId, previousProjectId) => {
// 项目切换后变量目录必须整体失效,防止串用上一项目的变量。
if (!nextProjectId || nextProjectId !== previousProjectId) {
resetVariableCatalog()
}
},
{ immediate: true },
)
watch(
() => [projectId.value, hasBoundLayers.value],
([nextProjectId, nextHasBoundLayers]) => {
// 只有项目已就绪且画布真的用到了绑定变量时,才懒加载变量目录。
if (!nextProjectId || !nextHasBoundLayers) {
return
}
void ensureVariableCatalogLoaded()
},
{ immediate: true },
)
const state: CanvasRuntimeState = {
isLoadingVariables,
variableLoadError,
variableOptions,
variableMap,
backgroundSampleColor,
autoTextColor,
ensureVariableCatalogLoaded,
refreshVariableCatalog,
setBackgroundAverageColor,
}
provide(CanvasRuntimeKey, state)
return state
}
export function useCanvasRuntimeInject() {
return inject(CanvasRuntimeKey)!
}

View File

@@ -0,0 +1,46 @@
import { storeToRefs } from 'pinia'
import pinia, { useCanvasStore, useLayerStore } from '@/stores'
export function useCanvasState() {
const canvasStore = useCanvasStore(pinia)
const {
canvasId,
canvasList,
canvasZoom,
isHydratingCanvas,
canvasView,
activeCanvasThumbnail,
canvasInfo,
} = storeToRefs(canvasStore)
const layerStore = useLayerStore(pinia)
const {
selectedLayerId,
selectedLayerIds,
selectedGroupId,
isBaseLayerActive,
selectedLayer,
selectedGroup,
layerList,
boxSelect,
} = storeToRefs(layerStore)
// 将两个 store 的核心响应式字段收敛到一个组合入口,便于编辑器各模块共享。
return {
canvasId,
canvasList,
canvasZoom,
isHydratingCanvas,
canvasView,
activeCanvasThumbnail,
canvasInfo,
selectedLayerId,
selectedLayerIds,
selectedGroupId,
isBaseLayerActive,
selectedLayer,
selectedGroup,
layerList,
boxSelect,
}
}

View File

@@ -0,0 +1,74 @@
import type { Layer } from '../types'
import { computed, ref } from 'vue'
import pinia, { useLayerStore } from '@/stores'
import { useLayerSelection } from '../composables/layer'
import { useCanvasState } from '../context/state'
import { getLayerGroupById, getLayerGroupMeta, remapLayerGroupIds } from '../grouping'
import { clamp, cloneValue, createLayerId, snapToGrid } from '../utils'
const GRID_SIZE = 10
const PASTE_OFFSET = 20
export function useClipboard() {
const { canvasView, layerList, selectedLayerIds } = useCanvasState()
const layerStore = useLayerStore(pinia)
const copiedLayers = ref<Layer[]>([])
const pasteRound = ref(0)
const hasCopiedLayers = computed(() => copiedLayers.value.length > 0)
function copySelected() {
if (!selectedLayerIds.value.length) {
copiedLayers.value = []
return
}
const selectedSet = new Set(selectedLayerIds.value)
copiedLayers.value = layerList.value
.filter(layer => selectedSet.has(layer.id))
.map(layer => cloneValue(layer))
pasteRound.value = 0
}
const { setSelection } = useLayerSelection()
function pasteCopied() {
if (!copiedLayers.value.length || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
pasteRound.value += 1
const offset = PASTE_OFFSET * pasteRound.value
const nextLayers = remapLayerGroupIds(copiedLayers.value).map((layer) => {
const nextX = clamp(snapToGrid(layer.x + offset, GRID_SIZE), 0, Math.max(0, canvasView.value.imageWidth - layer.width))
const nextY = clamp(snapToGrid(layer.y + offset, GRID_SIZE), 0, Math.max(0, canvasView.value.imageHeight - layer.height))
return {
...cloneValue(layer),
id: createLayerId(),
x: nextX,
y: nextY,
}
})
layerStore.appendLayers(nextLayers)
const nextIds = nextLayers.map(layer => layer.id)
const pastedGroupId = nextLayers[0] ? getLayerGroupMeta(nextLayers[0])?.groupId ?? '' : ''
const pastedGroup = getLayerGroupById(nextLayers, pastedGroupId)
if (pastedGroup && pastedGroup.layerIds.length === nextIds.length && nextIds.length > 1) {
setSelection(nextIds, nextIds[0] ?? null, { groupId: pastedGroup.groupId })
return
}
setSelection(nextIds, nextIds[0] ?? null)
}
function resetClipboard() {
copiedLayers.value = []
pasteRound.value = 0
}
return {
copySelected,
pasteCopied,
resetClipboard,
hasCopiedLayers,
}
}

View File

@@ -0,0 +1,36 @@
import { computed, inject, provide, ref } from 'vue'
import { useClipboard } from './ui.clipboard'
const EditorUIKey = Symbol('ui')
export function useEditorUIProvider() {
// 画布工具状态
const activeCanvasTool = ref('cursor')
// 光标样式直接由工具态派生,避免模板层再写一套判断。
const canvasCursorMode = computed(() => (activeCanvasTool.value === 'move' ? 'move' : 'default'))
// 设置当前激活的画布工具
function setActiveCanvasTool(tool: string) {
activeCanvasTool.value = tool
}
// 是否按下空格键(用于临时切换到抓手工具)
const isSpacePressed = ref(false)
const clipboard = useClipboard()
const state = {
activeCanvasTool,
canvasCursorMode,
setActiveCanvasTool,
isSpacePressed,
clipboard,
}
provide(EditorUIKey, state)
return state
}
export type CanvasUIProvicerState = ReturnType<typeof useEditorUIProvider>
export function useEditorUIInject() {
return inject<CanvasUIProvicerState>(EditorUIKey)!
}

View File

@@ -0,0 +1,92 @@
import type { ComponentType } from '@cslab-dcs/schema'
import type { ResizeHandle } from './types'
import mitt from 'mitt'
interface PositionUpdate {
id: string
nextX: number
nextY: number
}
interface RectUpdate extends PositionUpdate {
nextWidth: number
nextHeight: number
options?: { ignoreLock?: boolean }
}
interface ViewportTargetBounds {
x: number
y: number
width: number
height: number
}
export interface EditorEventMap {
'componentPalette:dragStart': { event: DragEvent, type: ComponentType }
'componentPalette:pointerDown': { event: PointerEvent, type: ComponentType }
'canvasStage:dragOver': DragEvent
'canvasStage:drop': { event: DragEvent, type: string }
'canvasStage:backgroundPointerDown': PointerEvent
'canvasStage:layerClick': { event: MouseEvent, id: string }
'canvasStage:layerPointerDown': { event: PointerEvent, id: string }
'canvasStage:layerResizePointerDown': { event: PointerEvent, id: string, handle: ResizeHandle }
'canvasStage:groupPointerDown': { event: PointerEvent, groupId: string }
'canvasStage:groupResizePointerDown': { event: PointerEvent, groupId: string, handle: ResizeHandle }
'canvasStage:baseLayerResizePointerDown': { event: PointerEvent, handle: ResizeHandle }
'selection:backgroundPointerDown': PointerEvent
'baseLayer:setSize': { width: number, height: number, options?: { resetView?: boolean } }
'viewport:beginPan': PointerEvent
'viewport:setScale': { newScale: number, anchor?: { clientX: number, clientY: number } }
'viewport:panBy': { deltaX: number, deltaY: number }
'viewport:centerTarget': ViewportTargetBounds
'layer:updatePosition': { id: string, nextX: number, nextY: number }
'layer:updatePositions': PositionUpdate[]
'layer:updateRect': RectUpdate
'layer:updateRects': RectUpdate[]
'layer:updateField': { id: string, field: string, value: unknown, options?: { ignoreLock?: boolean } }
'layer:updateFields': { ids: string[], field: string, value: unknown, options?: { ignoreLock?: boolean } }
'propertyPanel:panelPointerdown': PointerEvent
'propertyPanel:suspend': undefined
'propertyPanel:resume': undefined
'propertyPanel:openPanel': undefined
'propertyPanel:closePanel': undefined
'propertyPanel:replaceBackground': { source: string }
'propertyPanel:removeBackground': undefined
'header:undo': undefined
'header:redo': undefined
'history:stateChange': { canUndo: boolean, canRedo: boolean, undoLabel: string, redoLabel: string }
'history:push': { label: string }
}
type EventName = keyof EditorEventMap
type VoidEventName = {
[K in EventName]: EditorEventMap[K] extends undefined ? K : never
}[EventName]
type ValueEventName = Exclude<EventName, VoidEventName>
const emitter = mitt<Record<string, unknown>>()
export function setEditorEmitter<K extends VoidEventName>(type: K): void
export function setEditorEmitter<K extends ValueEventName>(type: K, value: EditorEventMap[K]): void
export function setEditorEmitter<K extends EventName>(type: K, value?: EditorEventMap[K]) {
// 编辑器内部模块通过统一事件总线通信,尽量避免跨层级直接互相引用。
emitter.emit(type, value as unknown)
}
export function listenEditorEmitter<K extends EventName>(
type: K,
handler: (value: EditorEventMap[K]) => void,
) {
emitter.on(type, handler as (value: unknown) => void)
}
export function removeEditorEmitter(types: EventName[] = []) {
if (!types.length) {
emitter.all.clear()
return
}
types.forEach((type) => {
emitter.all.delete(type)
})
}

View File

@@ -0,0 +1,221 @@
import type { Layer, LayerGroup } from './types'
import { get } from 'es-toolkit/compat'
type UnknownRecord = Record<string, unknown>
const MIXED_VALUE = Symbol('mixed-value')
export function asRecord(value: unknown): UnknownRecord | null {
if (!value || typeof value !== 'object') {
return null
}
return value as UnknownRecord
}
function readStringValue(source: UnknownRecord | null, keys: string[]) {
if (!source) {
return null
}
for (const key of keys) {
const value = source[key]
if (typeof value !== 'string') {
continue
}
const normalized = value.trim()
if (normalized) {
return normalized
}
}
return null
}
export function createLayerGroupId() {
return `group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export function getLayerGroupMeta(layer: Layer): { groupId: string, groupName: string } | null {
const layerRecord = asRecord(layer)
const configRecord = asRecord(layer.config)
const layerMetadataRecord = asRecord(layerRecord?.metadata)
const configMetadataRecord = asRecord(configRecord?.metadata)
const sources = [layerRecord, layerMetadataRecord, configRecord, configMetadataRecord]
for (const source of sources) {
const groupId = readStringValue(source, ['groupId', 'group_id'])
if (!groupId) {
continue
}
const groupName = readStringValue(source, ['groupName', 'group_name', 'groupLabel'])
|| `分组-${groupId.slice(-6)}`
return {
groupId,
groupName,
}
}
return null
}
export function withLayerGroupMeta(
layer: Layer,
meta: { groupId: string, groupName: string } | null,
) {
const currentMetadata = asRecord(layer.config?.metadata) ?? {}
const nextMetadata = {
...currentMetadata,
}
delete nextMetadata.groupId
delete nextMetadata.groupName
delete nextMetadata.group_id
delete nextMetadata.group_name
if (meta) {
nextMetadata.groupId = meta.groupId
nextMetadata.groupName = meta.groupName
}
return {
...layer,
config: {
...layer.config,
metadata: nextMetadata,
},
}
}
export function buildLayerGroups(layers: Layer[]): LayerGroup[] {
const groups = new Map<string, LayerGroup>()
layers.forEach((layer) => {
const meta = getLayerGroupMeta(layer)
if (!meta) {
return
}
const existing = groups.get(meta.groupId)
if (existing) {
// 分组包围盒在遍历过程中持续扩张,避免额外二次扫描。
const minX = Math.min(existing.x, layer.x)
const minY = Math.min(existing.y, layer.y)
const maxX = Math.max(existing.x + existing.width, layer.x + layer.width)
const maxY = Math.max(existing.y + existing.height, layer.y + layer.height)
existing.layerIds.push(layer.id)
existing.layers.push(layer)
existing.x = minX
existing.y = minY
existing.width = maxX - minX
existing.height = maxY - minY
return
}
groups.set(meta.groupId, {
groupId: meta.groupId,
groupName: meta.groupName,
layerIds: [layer.id],
layers: [layer],
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
})
})
return [...groups.values()]
}
export function getLayerGroupById(layers: Layer[], groupId: string) {
if (!groupId) {
return null
}
return buildLayerGroups(layers).find(group => group.groupId === groupId) ?? null
}
export function getLayerGroupByLayerId(layers: Layer[], layerId: string) {
if (!layerId) {
return null
}
return buildLayerGroups(layers).find(group => group.layerIds.includes(layerId)) ?? null
}
export function remapLayerGroupIds(layers: Layer[]) {
const groupIdMap = new Map<string, string>()
return layers.map((layer) => {
const meta = getLayerGroupMeta(layer)
if (!meta) {
return layer
}
const nextGroupId = groupIdMap.get(meta.groupId) ?? createLayerGroupId()
if (!groupIdMap.has(meta.groupId)) {
groupIdMap.set(meta.groupId, nextGroupId)
}
return withLayerGroupMeta(layer, {
groupId: nextGroupId,
groupName: meta.groupName,
})
})
}
export function buildGroupPositionUpdates(
layers: Layer[],
deltaX: number,
deltaY: number,
) {
return layers.map(layer => ({
id: layer.id,
nextX: Math.round(layer.x + deltaX),
nextY: Math.round(layer.y + deltaY),
}))
}
export function buildGroupRectUpdates(
layers: Layer[],
currentBounds: Pick<LayerGroup, 'x' | 'y' | 'width' | 'height'>,
nextBounds: Pick<LayerGroup, 'x' | 'y' | 'width' | 'height'>,
) {
// 分组缩放按相对位置和比例计算成员新矩形,保持组内布局关系不变。
const widthRatio = currentBounds.width === 0 ? 1 : nextBounds.width / currentBounds.width
const heightRatio = currentBounds.height === 0 ? 1 : nextBounds.height / currentBounds.height
return layers.map((layer) => {
const relativeX = currentBounds.width === 0 ? 0 : (layer.x - currentBounds.x) / currentBounds.width
const relativeY = currentBounds.height === 0 ? 0 : (layer.y - currentBounds.y) / currentBounds.height
return {
id: layer.id,
nextX: Math.round(nextBounds.x + relativeX * nextBounds.width),
nextY: Math.round(nextBounds.y + relativeY * nextBounds.height),
nextWidth: Math.round(layer.width * widthRatio),
nextHeight: Math.round(layer.height * heightRatio),
}
})
}
function isSameValue(left: unknown, right: unknown) {
return JSON.stringify(left) === JSON.stringify(right)
}
export function getCommonLayerFieldValue(layers: Layer[], field: string) {
if (!layers.length) {
return undefined
}
const [firstLayer, ...restLayers] = layers
const firstValue = get(firstLayer, field)
// 属性面板批量编辑时,用特殊标记区分“真的空值”和“多值混合”。
if (restLayers.every(layer => isSameValue(get(layer, field), firstValue))) {
return firstValue
}
return MIXED_VALUE
}
export function isMixedGroupFieldValue(value: unknown): value is typeof MIXED_VALUE {
return value === MIXED_VALUE
}

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import { useCanvasStore } from '@/stores'
import PageFormDialog from './components/page-form-dialog.vue'
import { useCanvasPageProvider, useCanvasProvider, useCanvasRuntimeProvider, useEditorUIProvider } from './context'
import { removeEditorEmitter } from './emitter'
import { useKeyAndPointerListener, usePageChangeListener, useWheelListener } from './listeners'
import { CanvasStage, EditorSidebar, PropertyPanel } from './modules'
import CanvasMinimap from './modules/minimap/index.vue'
// 加载画布列表,确保切换画布时数据已准备就绪
const canvasStore = useCanvasStore()
canvasStore.getCanvasList()
const provider = useCanvasProvider()
// eslint-disable-next-line ts/ban-ts-comment
// @ts-ignore
const { editorRef, stageRef, propertyPanelRef, sidebarRef } = provider
useCanvasRuntimeProvider()
const uiProvider = useEditorUIProvider()
// 事件监听
useKeyAndPointerListener(provider, uiProvider)
useWheelListener(editorRef)
usePageChangeListener()
// 画布页签管理
useCanvasPageProvider()
// 组件卸载时清理所有监听器和事件
onBeforeUnmount(removeEditorEmitter)
</script>
<template>
<section ref="editorRef" class="canvas-editor relative w-full h-full flex">
<EditorSidebar ref="sidebarRef" class="flex-shrink-0" />
<div class="relative flex-1 min-w-0 h-full">
<CanvasStage ref="stageRef" class="w-full h-full" />
<CanvasMinimap />
</div>
<PropertyPanel ref="propertyPanelRef" />
<PageFormDialog />
</section>
</template>
<style>
@import '../styles/editor-tokens.css';
</style>

View File

@@ -0,0 +1,3 @@
export * from './key-pointer'
export * from './page'
export * from './wheel'

View File

@@ -0,0 +1,435 @@
import type { ComponentType } from '@cslab-dcs/schema'
import type { CanvasProviderState, CanvasUIProvicerState } from '../context'
import type { ResizeHandle } from '../types'
import { useEventListener } from '@vueuse/core'
import { reactive, ref } from 'vue'
import { MAX_CANVAS_SIZE, MIN_CANVAS_HEIGHT, MIN_CANVAS_WIDTH } from '@/constants'
import pinia, { useLayerStore } from '@/stores'
import { useLayerSelection } from '../composables/layer'
import { useCanvasState } from '../context/state'
import { listenEditorEmitter, setEditorEmitter } from '../emitter'
// 工具栏创建工具 ID → 组件类型的映射
const CREATION_TOOL_TYPE_MAP: Record<string, ComponentType> = {
frame: 'text',
rect: 'rect',
number: 'number',
button: 'button',
}
function isEditableElement(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
return target.isContentEditable
|| target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.tagName === 'SELECT'
}
function hasNativeTextSelection() {
if (typeof window === 'undefined') {
return false
}
const selection = window.getSelection()
return Boolean(selection && !selection.isCollapsed && selection.toString().trim())
}
function resolveHtmlElement(target: EventTarget | null) {
return target instanceof HTMLElement ? target : null
}
function isLayerCopyShortcutContext(provider: CanvasProviderState, event: KeyboardEvent) {
const stageWrapper = provider.getWrapperElement()
const candidates = [
resolveHtmlElement(event.target),
resolveHtmlElement(typeof document !== 'undefined' ? document.activeElement : null),
].filter(Boolean) as HTMLElement[]
return candidates.some((candidate) => {
if (stageWrapper?.contains(candidate)) {
return true
}
return Boolean(candidate.closest('[data-layer-list-shortcuts="true"]'))
})
}
// 底图锁定宽高比时,拖动单边控制点也要回推另一边的尺寸。
function normalizeLockedSizeByWidth(width: number, ratio: number) {
let nextWidth = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, Math.round(width)))
let nextHeight = Math.round(nextWidth / ratio)
if (nextHeight < MIN_CANVAS_HEIGHT) {
nextHeight = MIN_CANVAS_HEIGHT
nextWidth = Math.round(nextHeight * ratio)
}
if (nextWidth < MIN_CANVAS_WIDTH) {
nextWidth = MIN_CANVAS_WIDTH
nextHeight = Math.round(nextWidth / ratio)
}
if (nextHeight > MAX_CANVAS_SIZE) {
nextHeight = MAX_CANVAS_SIZE
nextWidth = Math.round(nextHeight * ratio)
}
if (nextWidth > MAX_CANVAS_SIZE) {
nextWidth = MAX_CANVAS_SIZE
nextHeight = Math.round(nextWidth / ratio)
}
return {
nextWidth: Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, nextWidth)),
nextHeight: Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, nextHeight)),
}
}
function normalizeLockedSizeByHeight(height: number, ratio: number) {
let nextHeight = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, Math.round(height)))
let nextWidth = Math.round(nextHeight * ratio)
if (nextWidth < MIN_CANVAS_WIDTH) {
nextWidth = MIN_CANVAS_WIDTH
nextHeight = Math.round(nextWidth / ratio)
}
if (nextHeight < MIN_CANVAS_HEIGHT) {
nextHeight = MIN_CANVAS_HEIGHT
nextWidth = Math.round(nextHeight * ratio)
}
if (nextWidth > MAX_CANVAS_SIZE) {
nextWidth = MAX_CANVAS_SIZE
nextHeight = Math.round(nextWidth / ratio)
}
if (nextHeight > MAX_CANVAS_SIZE) {
nextHeight = MAX_CANVAS_SIZE
nextWidth = Math.round(nextHeight * ratio)
}
return {
nextWidth: Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, nextWidth)),
nextHeight: Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, nextHeight)),
}
}
export function useKeyAndPointerListener(provider: CanvasProviderState, uiProvider: CanvasUIProvicerState, history?: { undo: () => void, redo: () => void }) {
// 这里负责“全局输入”,包括快捷键、面板外拖放和底图缩放手柄。
const isPointerDragging = ref(false)
const fallbackDragType = ref<ComponentType>('number')
const baseLayerResizeState = reactive({
active: false,
handle: null as ResizeHandle | null,
startX: 0,
startY: 0,
startWidth: 0,
startHeight: 0,
})
const { canvasView } = useCanvasState()
const {
clearSelection,
removeSelectedLayers,
bringSelectedToFront,
sendSelectedToBack,
groupSelected,
ungroupSelected,
placeLayerAtClientPoint,
} = useLayerSelection()
useEventListener(window, 'pointerup', (event) => {
if (!isPointerDragging.value) {
return
}
isPointerDragging.value = false
setEditorEmitter('propertyPanel:resume')
const pointer = provider.getPointerImagePosition(event.clientX, event.clientY)
// 非原生 drag 模式下,靠 pointerup 时的位置补一个”放入画布”动作。
const placed = placeLayerAtClientPoint(pointer, fallbackDragType.value)
if (placed) {
setEditorEmitter('history:push', { label: '添加图层' })
}
})
function getPointerImagePosition(clientX: number, clientY: number) {
const canvas = provider.getCanvasElement()
if (!canvas || canvasView.value.scale <= 0) {
return null
}
const rect = canvas.getBoundingClientRect()
const canvasX = clientX - rect.left
const canvasY = clientY - rect.top
return {
imageX: (canvasX - canvasView.value.offsetX) / canvasView.value.scale,
imageY: (canvasY - canvasView.value.offsetY) / canvasView.value.scale,
}
}
function onBaseLayerResizePointerMove(event: PointerEvent) {
if (!baseLayerResizeState.active || !baseLayerResizeState.handle) {
return
}
const pointer = getPointerImagePosition(event.clientX, event.clientY)
if (!pointer) {
return
}
const deltaX = pointer.imageX - baseLayerResizeState.startX
const deltaY = pointer.imageY - baseLayerResizeState.startY
let nextWidth = baseLayerResizeState.startWidth
let nextHeight = baseLayerResizeState.startHeight
if (baseLayerResizeState.handle.includes('e')) {
nextWidth = baseLayerResizeState.startWidth + deltaX
}
if (baseLayerResizeState.handle.includes('w')) {
nextWidth = baseLayerResizeState.startWidth - deltaX
}
if (baseLayerResizeState.handle.includes('s')) {
nextHeight = baseLayerResizeState.startHeight + deltaY
}
if (baseLayerResizeState.handle.includes('n')) {
nextHeight = baseLayerResizeState.startHeight - deltaY
}
if (canvasView.value.backgroundLockAspectRatio && baseLayerResizeState.startWidth > 0 && baseLayerResizeState.startHeight > 0) {
const ratio = baseLayerResizeState.startWidth / baseLayerResizeState.startHeight
const hasHorizontalHandle = baseLayerResizeState.handle.includes('e') || baseLayerResizeState.handle.includes('w')
const hasVerticalHandle = baseLayerResizeState.handle.includes('n') || baseLayerResizeState.handle.includes('s')
if (hasHorizontalHandle && !hasVerticalHandle) {
const normalized = normalizeLockedSizeByWidth(nextWidth, ratio)
nextWidth = normalized.nextWidth
nextHeight = normalized.nextHeight
}
else if (!hasHorizontalHandle && hasVerticalHandle) {
const normalized = normalizeLockedSizeByHeight(nextHeight, ratio)
nextWidth = normalized.nextWidth
nextHeight = normalized.nextHeight
}
else {
const widthRatioDelta = Math.abs(nextWidth - baseLayerResizeState.startWidth) / Math.max(1, baseLayerResizeState.startWidth)
const heightRatioDelta = Math.abs(nextHeight - baseLayerResizeState.startHeight) / Math.max(1, baseLayerResizeState.startHeight)
if (widthRatioDelta >= heightRatioDelta) {
const normalized = normalizeLockedSizeByWidth(nextWidth, ratio)
nextWidth = normalized.nextWidth
nextHeight = normalized.nextHeight
}
else {
const normalized = normalizeLockedSizeByHeight(nextHeight, ratio)
nextWidth = normalized.nextWidth
nextHeight = normalized.nextHeight
}
}
}
else {
nextWidth = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, Math.round(nextWidth)))
nextHeight = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, Math.round(nextHeight)))
}
// 底图尺寸统一通过 emitter 分发,和属性面板改尺寸共用一套入口。
setEditorEmitter('baseLayer:setSize', { width: nextWidth, height: nextHeight })
}
useEventListener(window, 'pointermove', (event: PointerEvent) => {
onBaseLayerResizePointerMove(event)
})
function onBaseLayerResizePointerEnd() {
if (!baseLayerResizeState.active) {
return
}
baseLayerResizeState.active = false
baseLayerResizeState.handle = null
setEditorEmitter('propertyPanel:resume')
}
useEventListener(window, 'pointerup', () => {
onBaseLayerResizePointerEnd()
})
useEventListener(window, 'pointercancel', () => {
isPointerDragging.value = false
setEditorEmitter('propertyPanel:resume')
onBaseLayerResizePointerEnd()
})
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.code === 'Space' && !isEditableElement(event.target)) {
uiProvider.isSpacePressed.value = true
event.preventDefault()
return
}
if (isEditableElement(event.target)) {
return
}
const key = event.key.toLowerCase()
const withMeta = event.metaKey || event.ctrlKey
if (withMeta && !event.shiftKey && key === 'z') {
if (history) {
history.undo()
}
else {
setEditorEmitter('header:undo')
}
event.preventDefault()
return
}
if (withMeta && event.shiftKey && key === 'z') {
if (history) {
history.redo()
}
else {
setEditorEmitter('header:redo')
}
event.preventDefault()
return
}
// 编辑器快捷键只在非输入态生效,避免和表单输入冲突。
if (withMeta && key === 'c') {
if (hasNativeTextSelection()) {
return
}
if (!isLayerCopyShortcutContext(provider, event)) {
return
}
uiProvider.clipboard.copySelected()
event.preventDefault()
return
}
if (withMeta && key === 'v') {
uiProvider.clipboard.pasteCopied()
event.preventDefault()
return
}
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
removeSelectedLayers().then(() => {
setEditorEmitter('history:push', { label: '删除图层' })
})
return
}
if (event.altKey && event.key === ']') {
bringSelectedToFront()
setEditorEmitter('history:push', { label: '调整层级' })
event.preventDefault()
return
}
if (event.altKey && event.key === '[') {
sendSelectedToBack()
setEditorEmitter('history:push', { label: '调整层级' })
event.preventDefault()
return
}
if (withMeta && !event.shiftKey && key === 'g') {
const grouped = groupSelected()
if (grouped) {
setEditorEmitter('history:push', { label: '创建分组' })
event.preventDefault()
}
return
}
if (withMeta && event.shiftKey && key === 'g') {
event.preventDefault()
ungroupSelected().then((ungrouped) => {
if (ungrouped) {
setEditorEmitter('history:push', { label: '解除分组' })
}
})
}
})
useEventListener(window, 'keyup', (event: KeyboardEvent) => {
if (event.code === 'Space') {
uiProvider.isSpacePressed.value = false
}
})
function onComponentPalettePointerDown({ event, type }: { event: PointerEvent, type: ComponentType }) {
isPointerDragging.value = true
fallbackDragType.value = type
setEditorEmitter('propertyPanel:suspend')
event.preventDefault()
}
listenEditorEmitter('componentPalette:pointerDown', onComponentPalettePointerDown)
const layerStore = useLayerStore(pinia)
function onBaseLayerResizePointerDown(payload: { event: PointerEvent, handle: ResizeHandle }) {
const { event, handle } = payload
if (event.button !== 0 || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
const pointer = getPointerImagePosition(event.clientX, event.clientY)
if (!pointer) {
return
}
event.preventDefault()
setEditorEmitter('propertyPanel:suspend')
baseLayerResizeState.active = true
baseLayerResizeState.handle = handle
baseLayerResizeState.startX = pointer.imageX
baseLayerResizeState.startY = pointer.imageY
baseLayerResizeState.startWidth = canvasView.value.imageWidth
baseLayerResizeState.startHeight = canvasView.value.imageHeight
layerStore.activateBaseLayerSelection()
}
listenEditorEmitter('canvasStage:baseLayerResizePointerDown', onBaseLayerResizePointerDown)
function isPointInsideBaseLayer(point: { imageX: number, imageY: number }) {
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return false
}
return point.imageX >= 0
&& point.imageY >= 0
&& point.imageX <= canvasView.value.imageWidth
&& point.imageY <= canvasView.value.imageHeight
}
function onBackgroundPointerDown(event: PointerEvent) {
const shouldPan = event.button === 1
|| (event.button === 0 && (uiProvider.isSpacePressed.value || uiProvider.activeCanvasTool.value === 'move'))
if (shouldPan) {
// 空格或抓手工具下,背景点击优先进入视口平移模式。
setEditorEmitter('viewport:beginPan', event)
return
}
// 创建工具模式:点击画布直接在落点处创建对应类型的图层,然后切回选择工具。
const creationType = CREATION_TOOL_TYPE_MAP[uiProvider.activeCanvasTool.value]
if (creationType && event.button === 0) {
const pointer = provider.getPointerImagePosition(event.clientX, event.clientY)
const placed = placeLayerAtClientPoint(pointer, creationType)
if (placed) {
setEditorEmitter('history:push', { label: '添加图层' })
}
uiProvider.setActiveCanvasTool('cursor')
return
}
const pointer = provider.getPointerImagePosition(event.clientX, event.clientY)
if (!pointer || !isPointInsideBaseLayer(pointer)) {
layerStore.deactivateBaseLayerSelection()
clearSelection()
return
}
layerStore.activateBaseLayerSelection()
setEditorEmitter('selection:backgroundPointerDown', event)
}
listenEditorEmitter('canvasStage:backgroundPointerDown', onBackgroundPointerDown)
}

View File

@@ -0,0 +1,10 @@
import { useEventListener } from '@vueuse/core'
import { useLayerPersistence } from '../composables/persistence'
export function usePageChangeListener() {
const { persistComponentsLayerNow } = useLayerPersistence()
useEventListener(window, 'pagehide', () => {
// 页面被隐藏或刷新前尽量再落盘一次,降低“刚拖完就刷新”导致的数据丢失概率。
persistComponentsLayerNow()
})
}

View File

@@ -0,0 +1,50 @@
import type { ShallowRef } from 'vue'
import { useEventListener } from '@vueuse/core'
import { useCanvasState } from '../context/state'
import { setEditorEmitter } from '../emitter'
// 鼠标位于侧边面板(图层/属性)时,不拦截滚轮,避免误触发画布平移。
function isWheelOnPanel(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
return Boolean(target.closest('[data-editor-panel], .property-panel'))
}
export function useWheelListener(editorRef: ShallowRef<HTMLElement | null>) {
const { canvasView } = useCanvasState()
useEventListener(
editorRef,
'wheel',
(event: WheelEvent) => {
if (isWheelOnPanel(event.target)) {
return
}
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
// 组合键滚轮走缩放,并以鼠标位置作为锚点。
const factor = event.deltaY > 0 ? 1 / 1.08 : 1.08
const newScale = canvasView.value.scale * factor
const anchor = { clientX: event.clientX, clientY: event.clientY }
setEditorEmitter('viewport:setScale', { newScale, anchor })
return
}
const horizontalDelta = event.deltaX || (event.shiftKey ? event.deltaY : 0)
const verticalDelta = event.deltaY
if (!horizontalDelta && !verticalDelta) {
return
}
event.preventDefault()
// 与常见滚动方向一致:滚轮向下时画面向上移动,向右时画面向左移动。
const deltaX = -horizontalDelta
const deltaY = -verticalDelta
setEditorEmitter('viewport:panBy', { deltaX, deltaY })
},
{ passive: false },
)
}

View File

@@ -0,0 +1,8 @@
import type { ComponentPaletteItem } from './types'
export const COMPONENT_PALETTE_ITEMS: ComponentPaletteItem[] = [
{ title: '棒图', type: 'bar', icon: 'fa-solid fa-mattress-pillow', description: '分段变色柱状指示' },
{ title: '控制仪表', type: 'pidController', icon: 'fa-solid fa-gauge-high', description: 'PID 控制仪表面板' },
{ title: '阀门控制器', type: 'valveController', icon: 'fa-solid fa-faucet', description: '阀门开度控制面板' },
{ title: '切页按钮', type: 'canvasSwitcher', icon: 'fa-solid fa-layer-group', description: '画布 Tab 切换' },
]

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { ComponentType } from '@cslab-dcs/schema'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useAppStore } from '@/stores'
import { COMPONENT_PALETTE_ITEMS } from './constants'
const emits = defineEmits<{
(e: 'dragStart', event: DragEvent, type: ComponentType): void
(e: 'dragEnd', event: DragEvent): void
(e: 'pointerDown', event: PointerEvent, type: ComponentType): void
}>()
const appStore = useAppStore()
const { platformInfo } = storeToRefs(appStore)
// Tauri 环境下优先走 pointer 方案,避免原生 drag/drop 兼容问题。
const enableNativeDrag = computed(() => platformInfo.value?.name !== 'tauri')
function onComponentPaletteDragStart(event: DragEvent, type: ComponentType) {
if (!event.dataTransfer) {
return
}
emits('dragStart', event, type)
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('application/json', JSON.stringify({ type }))
}
function onComponentPaletteDragEnd(event: DragEvent) {
emits('dragEnd', event)
}
function onPointerDown(event: PointerEvent, type: ComponentType) {
if (enableNativeDrag.value) {
return
}
if (event.button !== 0) {
return
}
// 非原生拖拽环境把“按下”当作拖入画布的起点,由全局监听完成投放。
emits('pointerDown', event, type)
}
</script>
<template>
<section class="components-tab">
<p class="components-hint">
拖拽添加到画布
</p>
<div class="component-grid">
<div
v-for="item in COMPONENT_PALETTE_ITEMS"
:key="item.type"
class="component-card"
:draggable="enableNativeDrag"
@dragstart="onComponentPaletteDragStart($event, item.type)"
@dragend="onComponentPaletteDragEnd"
@pointerdown="onPointerDown($event, item.type)"
>
<div class="card-icon">
<i :class="item.icon" />
</div>
<div class="card-meta">
<div class="card-title">
{{ item.title }}
</div>
<div class="card-desc">
{{ item.description }}
</div>
</div>
</div>
</div>
</section>
</template>
<style lang="scss" scoped>
.components-tab {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--ed-space-4);
gap: var(--ed-space-4);
}
.components-hint {
margin: 0;
font-size: var(--ed-font-sm);
color: var(--ed-text-secondary);
font-weight: var(--ed-font-weight-semibold);
}
.component-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--ed-space-3);
overflow-y: auto;
}
.component-card {
border: 1px solid var(--ed-border);
border-radius: var(--ed-radius-md);
background: var(--ed-bg-raised);
min-height: 46px;
display: flex;
align-items: center;
gap: var(--ed-space-4);
padding: var(--ed-space-4);
cursor: grab;
transition: border-color var(--ed-transition-fast), box-shadow var(--ed-transition-fast), background-color var(--ed-transition-fast);
}
.component-card:hover {
border-color: var(--ed-accent);
background: var(--ed-accent-subtle);
box-shadow: var(--ed-shadow-sm);
}
.component-card:active {
cursor: grabbing;
}
.card-icon {
width: 24px;
height: 24px;
border-radius: var(--ed-radius-sm);
background: var(--ed-bg-surface);
display: flex;
align-items: center;
justify-content: center;
color: var(--ed-text-primary);
font-size: 12px;
}
.card-meta {
min-width: 0;
}
.card-title {
font-size: var(--ed-font-sm);
font-weight: var(--ed-font-weight-semibold);
color: var(--ed-text-primary);
}
.card-desc {
font-size: var(--ed-font-xs);
color: var(--ed-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,8 @@
import type { ComponentType } from '@cslab-dcs/schema'
export interface ComponentPaletteItem {
title: string
type: ComponentType
icon: string
description?: string
}

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import type { EditorSidebarToolItem } from '../types'
import { reactive } from 'vue'
import { useEditorUIInject } from '../../../context'
import ContextMenu from '../../shared/context-menu.vue'
import { EDITOR_SIDEBAR_TOOL_ITEMS } from '../constants'
const props = defineProps<{
collapsed: boolean
}>()
const emits = defineEmits<{
(e: 'toggle'): void
}>()
const { activeCanvasTool, setActiveCanvasTool } = useEditorUIInject()
const toolDropdown = reactive({
visible: false,
x: 0,
y: 0,
items: [] as Array<{ key: string, label?: string, type?: 'item' | 'separator', checked?: boolean }>,
parentId: '',
})
function openToolDropdown(event: MouseEvent, item: EditorSidebarToolItem) {
if (!item.options?.length)
return
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
toolDropdown.visible = true
toolDropdown.x = rect.right + 4
toolDropdown.y = rect.top
toolDropdown.parentId = item.id
toolDropdown.items = item.options.map(opt => ({
key: opt.id,
label: opt.title,
checked: opt.id === activeCanvasTool.value,
}))
}
function closeToolDropdown() {
toolDropdown.visible = false
}
function onToolDropdownSelect(key: string) {
setActiveCanvasTool(key)
closeToolDropdown()
}
function isToolActive(item: EditorSidebarToolItem) {
if (item.options?.length) {
return item.options.some(option => option.id === activeCanvasTool.value)
}
return item.id === activeCanvasTool.value
}
function getActiveToolOption(item: EditorSidebarToolItem) {
if (!item.options?.length) {
return null
}
return item.options.find(option => option.id === activeCanvasTool.value) || item.options[0]
}
function getToolTitle(item: EditorSidebarToolItem) {
return getActiveToolOption(item)?.title || item.title
}
function getToolIcon(item: EditorSidebarToolItem) {
return getActiveToolOption(item)?.icon || item.icon
}
</script>
<template>
<aside class="tool-rail">
<div class="tool-list">
<template v-for="item in EDITOR_SIDEBAR_TOOL_ITEMS" :key="item.id">
<div v-if="item.dividerBefore" class="tool-divider" />
<button
v-if="item.options?.length"
type="button"
class="tool-btn is-group"
:class="{ active: isToolActive(item) }"
:title="getToolTitle(item)"
@click="openToolDropdown($event, item)"
>
<i :class="getToolIcon(item)" />
<i class="fa-solid fa-angle-down tool-btn-caret" />
</button>
<button
v-else
type="button"
class="tool-btn"
:class="{ active: isToolActive(item) }"
:title="item.title"
@click="setActiveCanvasTool(item.id)"
>
<i :class="item.icon" />
</button>
</template>
</div>
<div class="tool-bottom">
<button
type="button"
class="tool-btn"
:title="props.collapsed ? '展开侧栏' : '收起侧栏'"
@click="emits('toggle')"
>
<i class="fa-solid" :class="[props.collapsed ? 'fa-angles-right' : 'fa-angles-left']" />
</button>
</div>
<ContextMenu
:visible="toolDropdown.visible"
:x="toolDropdown.x"
:y="toolDropdown.y"
:items="toolDropdown.items"
@close="closeToolDropdown"
@select="onToolDropdownSelect"
/>
</aside>
</template>
<style lang="scss" scoped>
.tool-rail {
width: var(--ed-toolbar-width);
height: 100%;
border-right: 1px solid var(--ed-border);
background: var(--ed-bg-surface);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.tool-list,
.tool-bottom {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 0;
gap: var(--ed-space-1);
}
.tool-divider {
width: 24px;
height: 1px;
background: var(--ed-border);
margin: var(--ed-space-3) 0;
}
.tool-btn {
appearance: none;
border: none;
margin: 0;
width: 28px;
height: 28px;
border-radius: var(--ed-radius-md);
color: var(--ed-text-primary);
background: transparent;
padding: 0;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--ed-transition-fast), color var(--ed-transition-fast);
}
.tool-btn:hover {
background: var(--ed-bg-hover);
}
.tool-btn.active {
color: var(--ed-accent);
background: var(--ed-accent-subtle);
}
.tool-btn.is-group {
position: relative;
}
.tool-btn-caret {
position: absolute;
right: 2px;
bottom: 2px;
font-size: 8px;
line-height: 1;
opacity: 0.75;
transform: rotate(-45deg);
}
</style>

View File

@@ -0,0 +1,25 @@
import type { EditorSidebarTabItem, EditorSidebarToolItem, EditorSidebarToolOption } from './types'
export const EDITOR_SIDEBAR_MOVE_TOOL_OPTIONS: EditorSidebarToolOption[] = [
{ id: 'cursor', title: '选择工具', icon: 'fa-solid fa-arrow-pointer', shortcut: 'V' },
{ id: 'move', title: '移动视图', icon: 'fa-regular fa-hand', shortcut: 'H' },
]
export const EDITOR_SIDEBAR_TOOL_ITEMS: EditorSidebarToolItem[] = [
{
id: 'move-tool',
title: '移动工具',
icon: 'fa-solid fa-location-arrow',
options: EDITOR_SIDEBAR_MOVE_TOOL_OPTIONS,
},
{ id: 'frame', title: '文本工具', icon: 'fa-solid fa-font', dividerBefore: true },
{ id: 'rect', title: '矩形工具', icon: 'fa-regular fa-square' },
{ id: 'number', title: '数值工具', icon: 'fa-solid fa-hashtag' },
{ id: 'button', title: '按钮工具', icon: 'fa-regular fa-hand-pointer' },
]
export const EDITOR_SIDEBAR_TABS: EditorSidebarTabItem[] = [
{ key: 'layers', label: '图层' },
{ key: 'components', label: '组件' },
{ key: 'templates', label: '模板' },
]

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import type { ComponentType } from '@cslab-dcs/schema'
import type { EditorSidebarTabKey } from './types'
import { ref } from 'vue'
import { setEditorEmitter } from '../../emitter'
import ComponentPalette from '../component-palette/index.vue'
import LayersPanel from '../layers-panel/index.vue'
import TemplateLibrary from '../template-library/index.vue'
import ToolRail from './components/tool-rail.vue'
import { EDITOR_SIDEBAR_TABS } from './constants'
const activeTab = ref<EditorSidebarTabKey>('layers')
const collapsed = ref(false)
function onToggle() {
collapsed.value = !collapsed.value
}
function onDragStart(event: DragEvent, type: ComponentType) {
setEditorEmitter('propertyPanel:suspend')
setEditorEmitter('componentPalette:dragStart', { event, type })
}
function onDragEnd() {
setEditorEmitter('propertyPanel:resume')
}
function onPointerDown(event: PointerEvent, type: ComponentType) {
setEditorEmitter('componentPalette:pointerDown', { event, type })
}
</script>
<template>
<aside class="editor-sidebar-shell" :class="{ 'is-collapsed': collapsed }">
<ToolRail :collapsed="collapsed" @toggle="onToggle" />
<section v-show="!collapsed" class="editor-sidebar-panel" data-editor-panel>
<div class="sidebar-tab-bar">
<button
v-for="tab in EDITOR_SIDEBAR_TABS"
:key="tab.key"
type="button"
class="sidebar-tab-item"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<div class="sidebar-tab-content">
<LayersPanel v-if="activeTab === 'layers'" />
<ComponentPalette
v-else-if="activeTab === 'components'"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@pointer-down="onPointerDown"
/>
<TemplateLibrary
v-else-if="activeTab === 'templates'"
/>
</div>
</section>
</aside>
</template>
<style lang="scss" scoped>
.editor-sidebar-shell {
position: relative;
z-index: 8;
display: flex;
height: 100%;
flex: 0 0 auto;
pointer-events: auto;
}
.editor-sidebar-panel {
width: var(--ed-sidebar-width);
height: 100%;
background: var(--ed-bg-surface);
border-right: 1px solid var(--ed-border);
display: flex;
flex-direction: column;
}
.editor-sidebar-shell button,
.editor-sidebar-shell input,
.editor-sidebar-shell textarea,
.editor-sidebar-shell select {
font-family: inherit;
}
.editor-sidebar-shell.is-collapsed .editor-sidebar-panel {
width: 0;
border-right: none;
overflow: hidden;
}
.sidebar-tab-bar {
height: 32px;
display: flex;
align-items: stretch;
border-bottom: 1px solid var(--ed-border);
padding: 0 10px;
background: var(--ed-bg-surface);
flex-shrink: 0;
}
.sidebar-tab-item {
appearance: none;
border: none;
background: transparent;
padding: 0 6px;
font-size: var(--ed-font-sm);
font-weight: var(--ed-font-weight-medium);
color: var(--ed-text-tertiary);
cursor: pointer;
position: relative;
transition: color var(--ed-transition-fast);
}
.sidebar-tab-item:hover {
color: var(--ed-text-secondary);
}
.sidebar-tab-item.active {
color: var(--ed-text-primary);
}
.sidebar-tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 6px;
right: 6px;
height: 2px;
background: var(--ed-accent);
border-radius: 1px;
}
.sidebar-tab-content {
flex: 1;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,22 @@
export type EditorSidebarTabKey = 'layers' | 'components' | 'templates' | 'resources' | 'ai'
export interface EditorSidebarToolOption {
id: string
title: string
icon: string
shortcut?: string
}
export interface EditorSidebarToolItem {
id: string
title: string
icon: string
dividerBefore?: boolean
options?: EditorSidebarToolOption[]
}
export interface EditorSidebarTabItem {
key: EditorSidebarTabKey
label: string
showNew?: boolean
}

View File

@@ -0,0 +1,3 @@
export { default as EditorSidebar } from './editor-sidebar/index.vue'
export { default as PropertyPanel } from './property-panel/index.vue'
export { default as CanvasStage } from './stage/index.vue'

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
/**
* 位号搜索视图 — 按设备位号搜索和分组展示图层
*/
import type { Layer } from '../../types'
import { computed, ref } from 'vue'
import { EdInput } from '@/components/editor/controls'
import { useCanvasRuntimeInject } from '../../context'
import { filterLayersByTag, groupLayersByTag, resolveComponentIcon, resolveLayerName } from './utils'
const props = defineProps<{
layers: Layer[]
}>()
const emit = defineEmits<{
(e: 'select', layerId: string): void
}>()
const { variableMap, ensureVariableCatalogLoaded } = useCanvasRuntimeInject()
// 确保变量目录已加载,否则无法将 moduleName 映射到 moduleLabel
ensureVariableCatalogLoaded()
const searchKeyword = ref('')
const filteredLayers = computed(() =>
filterLayersByTag(props.layers, searchKeyword.value),
)
// 通过 variableMap 将 moduleName位号映射到 moduleLabel设备标签
function resolveTagLabel(tagNumber: string) {
const vars = variableMap.value
for (const v of Object.values(vars)) {
if (v.moduleName === tagNumber) {
return v.moduleLabel
}
}
return undefined
}
const groupedLayers = computed(() =>
groupLayersByTag(filteredLayers.value, resolveTagLabel),
)
const collapsedGroups = ref<Set<string>>(new Set())
function toggleGroup(prefix: string) {
if (collapsedGroups.value.has(prefix)) {
collapsedGroups.value.delete(prefix)
}
else {
collapsedGroups.value.add(prefix)
}
}
function isGroupCollapsed(prefix: string) {
return collapsedGroups.value.has(prefix)
}
function getLayerDisplayName(layer: Layer, index: number): string {
return resolveLayerName(layer, index)
}
function onLayerClick(layerId: string) {
emit('select', layerId)
}
</script>
<template>
<div class="tag-number-view">
<div class="layer-search-row">
<EdInput v-model="searchKeyword" placeholder="搜索位号" />
</div>
<div class="layer-list">
<template v-for="group in groupedLayers" :key="group.prefix">
<!-- 分组头 -->
<div class="layer-row is-group-row" @click="toggleGroup(group.prefix)">
<div class="layer-left has-caret" style="padding-left: 10px;">
<i
class="fa-solid fa-caret-right group-caret"
:class="{ 'is-collapsed': isGroupCollapsed(group.prefix) }"
/>
<button type="button" class="layer-icon-button">
<i class="layer-glyph fa-regular fa-folder" />
</button>
<button type="button" class="layer-name-button">
{{ group.prefix }}
</button>
</div>
<div class="layer-row-tail">
<span class="group-count">{{ group.items.length }}</span>
</div>
</div>
<!-- 子项 -->
<template v-if="!isGroupCollapsed(group.prefix)">
<div
v-for="(layer, index) in group.items"
:key="layer.id"
class="layer-row"
@click="onLayerClick(layer.id)"
>
<div class="layer-left" style="padding-left: 32px;">
<button type="button" class="layer-icon-button">
<i class="layer-glyph" :class="resolveComponentIcon(layer.type)" />
</button>
<button type="button" class="layer-name-button">
{{ getLayerDisplayName(layer, index) }}
</button>
</div>
<div v-if="layer.tagNumber" class="layer-row-tail">
<span class="tag-badge">{{ layer.tagNumber }}</span>
</div>
</div>
</template>
</template>
<div v-if="groupedLayers.length === 0" class="tag-empty">
无匹配位号
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@use './layer-row.scss';
.tag-number-view {
display: flex;
flex-direction: column;
min-height: 0;
}
.layer-row-tail {
display: flex;
align-items: center;
padding-right: 8px;
flex-shrink: 0;
}
.group-count {
font-size: 11px;
font-weight: 400;
color: var(--ed-text-tertiary);
}
.tag-badge {
font-size: 11px;
color: var(--ed-text-tertiary);
font-family: var(--ed-font-mono, monospace);
}
.tag-empty {
text-align: center;
color: var(--ed-text-tertiary);
font-size: 12px;
padding: 20px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
// 共享的图层行样式 — 供 index.vue 和 TagNumberView.vue 复用
.layer-search-row {
height: 36px;
display: flex;
align-items: center;
padding: 0 8px;
gap: 4px;
border-bottom: 1px solid var(--ed-border);
}
.layer-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 2px 8px 0;
display: flex;
flex-direction: column;
gap: 0;
contain: layout style;
}
.layer-list:focus {
outline: none;
}
.layer-row {
height: 30px;
border-radius: 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
position: relative;
padding-right: 0;
}
.layer-row:not(.is-group-row):not(.active):not(.active-group):not(.is-renaming):hover {
background: #ffffff;
box-shadow: inset 0 0 0 1px var(--ed-accent);
}
.layer-row.is-group-row {
font-weight: 500;
}
.layer-row.is-group-row:hover {
background: var(--ed-bg-hover);
}
.layer-left {
display: flex;
align-items: center;
min-width: 0;
position: relative;
flex: 1;
height: 100%;
}
.layer-left.has-caret {
padding-left: 10px !important;
}
.layer-icon-button,
.layer-name-button {
appearance: none;
border: none;
background: transparent;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
}
.layer-icon-button {
width: 16px;
height: 16px;
margin-left: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex: 0 0 auto;
}
.layer-glyph {
font-size: 12px;
color: var(--ed-text-tertiary);
}
.layer-name-button {
margin-left: 4px;
min-width: 0;
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--ed-text-primary);
font-weight: 400;
cursor: pointer;
}
.group-caret {
font-size: 12px;
color: var(--ed-text-tertiary);
transition: transform 0.15s ease;
transform: rotate(90deg);
margin-left: -3px;
margin-right: 4px;
&.is-collapsed {
transform: rotate(0deg);
}
}

View File

@@ -0,0 +1,19 @@
export type PaletteLayerKind = 'group' | 'component'
export type PaletteLayerVisibilityState = 'visible' | 'hidden' | 'mixed'
export type PaletteLayerLockState = 'locked' | 'unlocked' | 'mixed'
export interface PaletteLayerItem {
id: string
name: string
kind?: PaletteLayerKind
groupId?: string
componentId?: string
icon?: string
selectable?: boolean
level?: number
locked?: boolean
visible?: boolean
visibilityState?: PaletteLayerVisibilityState
lockState?: PaletteLayerLockState
hasLockedDescendants?: boolean
}

View File

@@ -0,0 +1,144 @@
import type { Layer } from '../../types'
import type { PaletteLayerItem } from './types'
import { asRecord, getLayerGroupMeta } from '../../grouping'
function readStringValue(source: Record<string, unknown> | null, keys: string[]): string | null {
if (!source) {
return null
}
for (const key of keys) {
const value = source[key]
if (typeof value === 'string') {
const normalized = value.trim()
if (normalized) {
return normalized
}
}
}
return null
}
export function resolveLayerName(layer: Layer, index: number): string {
const layerRecord = asRecord(layer as unknown)
const configRecord = asRecord(layer.config)
const directName = readStringValue(layerRecord, ['name', 'title', 'label'])
|| readStringValue(configRecord, ['name', 'title', 'label'])
if (directName) {
return directName
}
const typeLabel: Record<string, string> = {
rect: '矩形',
number: '数值',
text: '文本',
bar: '棒图',
progress: '进度条',
button: '按钮',
custom: '组件',
}
return `${typeLabel[layer.type] || layer.type}-${index + 1}`
}
export function resolveComponentIcon(type: string) {
const iconMap: Record<string, string> = {
rect: 'fa-regular fa-square',
number: 'fa-solid fa-hashtag',
text: 'fa-solid fa-font',
bar: 'fa-solid fa-mattress-pillow',
progress: 'fa-solid fa-gauge',
button: 'fa-regular fa-hand-pointer',
custom: 'fa-regular fa-square',
}
return iconMap[type] || 'fa-regular fa-square'
}
export const resolveGroupMeta = getLayerGroupMeta
/** 按位号标签分组图层 */
export function groupLayersByTag(
layers: Layer[],
labelResolver?: (tagNumber: string) => string | undefined,
): { prefix: string, items: Layer[] }[] {
const groups = new Map<string, Layer[]>()
const unassigned: Layer[] = []
for (const layer of layers) {
const tag = layer.tagNumber
if (!tag) {
unassigned.push(layer)
continue
}
const groupKey = labelResolver?.(tag) || tag
if (!groups.has(groupKey))
groups.set(groupKey, [])
groups.get(groupKey)!.push(layer)
}
const sortedEntries = [...groups.entries()]
sortedEntries.sort(([a], [b]) => a.localeCompare(b))
const result = sortedEntries.map(([prefix, items]) => ({ prefix, items }))
if (unassigned.length > 0) {
result.push({ prefix: '未分配', items: unassigned })
}
return result
}
/** 按位号关键词过滤图层 */
export function filterLayersByTag(layers: Layer[], keyword: string): Layer[] {
if (!keyword.trim())
return layers
const kw = keyword.toLowerCase()
return layers.filter(l => l.tagNumber?.toLowerCase().includes(kw))
}
export function filterLayers(layers: PaletteLayerItem[], keyword: string): PaletteLayerItem[] {
const normalized = keyword.trim().toLowerCase()
if (!normalized) {
return layers
}
const result: PaletteLayerItem[] = []
let index = 0
while (index < layers.length) {
const current = layers[index]
const currentLevel = current.level || 0
if (current.kind !== 'group') {
if (current.name.toLowerCase().includes(normalized)) {
result.push(current)
}
index += 1
continue
}
const children: PaletteLayerItem[] = []
let cursor = index + 1
while (cursor < layers.length) {
const next = layers[cursor]
if ((next.level || 0) <= currentLevel) {
break
}
children.push(next)
cursor += 1
}
const matchedChildren = children.filter(child => child.name.toLowerCase().includes(normalized))
const groupMatched = current.name.toLowerCase().includes(normalized)
if (groupMatched || matchedChildren.length > 0) {
result.push(current)
result.push(...(groupMatched ? children : matchedChildren))
}
index = cursor
}
return result
}

View File

@@ -0,0 +1,366 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { useSessionStorage } from '@vueuse/core'
import { computed, onBeforeUnmount, shallowRef } from 'vue'
import { useCanvasState } from '../../context/state'
import { setEditorEmitter } from '../../emitter'
// ── 常量 ──
const MINIMAP_WIDTH = 160
const MINIMAP_HEIGHT = 120
const MINIMAP_PADDING = 8
// ── 状态 ──
const { canvasView, layerList, activeCanvasThumbnail } = useCanvasState()
const collapsed = useSessionStorage('DCS_MINIMAP_COLLAPSED', false)
const minimapRef = shallowRef<HTMLDivElement | null>(null)
const isDragging = shallowRef(false)
// ── 可见图层 ──
const visibleLayers = computed(() =>
layerList.value.filter(layer => layer.config?.visible !== false),
)
// ── 内容边界(图层 + 画布底图区域的并集) ──
const contentBounds = computed(() => {
const iw = canvasView.value.imageWidth
const ih = canvasView.value.imageHeight
// 起始边界 = 底图区域
let minX = 0
let minY = 0
let maxX = iw || 1920
let maxY = ih || 1080
// 将所有图层纳入边界
for (const layer of visibleLayers.value) {
minX = Math.min(minX, layer.x)
minY = Math.min(minY, layer.y)
maxX = Math.max(maxX, layer.x + layer.width)
maxY = Math.max(maxY, layer.y + layer.height)
}
const width = maxX - minX || 1
const height = maxY - minY || 1
return { minX, minY, maxX, maxY, width, height }
})
// ── 缩放因子(将内容区域适配到小地图尺寸) ──
const minimapScale = computed(() => {
const { width, height } = contentBounds.value
const innerW = MINIMAP_WIDTH - MINIMAP_PADDING * 2
const innerH = MINIMAP_HEIGHT - MINIMAP_PADDING * 2
return Math.min(innerW / width, innerH / height)
})
// ── 图层矩形样式 ──
function getLayerRectStyle(layer: { x: number, y: number, width: number, height: number, style?: Record<string, any>, config?: Record<string, any>, type: string }): CSSProperties {
const scale = minimapScale.value
const { minX, minY } = contentBounds.value
const x = (layer.x - minX) * scale + MINIMAP_PADDING
const y = (layer.y - minY) * scale + MINIMAP_PADDING
const w = Math.max(layer.width * scale, 1)
const h = Math.max(layer.height * scale, 1)
// 尝试从图层样式或配置中提取颜色
const fillColor
= layer.style?.fill?.color
|| layer.config?.fillColor
|| layer.style?.background
|| getDefaultColorByType(layer.type)
return {
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
width: `${w}px`,
height: `${h}px`,
background: fillColor,
borderRadius: '0.5px',
}
}
function getDefaultColorByType(type: string): string {
switch (type) {
case 'rect':
return 'rgba(22, 119, 255, 0.5)'
case 'text':
return 'rgba(100, 100, 100, 0.5)'
case 'number':
return 'rgba(10, 192, 94, 0.5)'
case 'bar':
return 'rgba(250, 173, 20, 0.5)'
case 'button':
return 'rgba(22, 119, 255, 0.6)'
default:
return 'rgba(22, 119, 255, 0.4)'
}
}
// ── 底图区域样式 ──
const baseLayerStyle = computed<CSSProperties>(() => {
const scale = minimapScale.value
const { minX, minY } = contentBounds.value
const iw = canvasView.value.imageWidth || 1920
const ih = canvasView.value.imageHeight || 1080
const bg = canvasView.value.background || '#FFFFFF'
const thumbnail = activeCanvasThumbnail.value
return {
position: 'absolute',
left: `${(0 - minX) * scale + MINIMAP_PADDING}px`,
top: `${(0 - minY) * scale + MINIMAP_PADDING}px`,
width: `${iw * scale}px`,
height: `${ih * scale}px`,
background: thumbnail ? `url(${thumbnail}) center / cover no-repeat` : bg,
border: '1px solid var(--ed-border)',
}
})
// ── 视口指示器样式 ──
const viewportStyle = computed<CSSProperties | null>(() => {
const { scale: viewScale, offsetX, offsetY, canvasWidth, canvasHeight } = canvasView.value
if (!canvasWidth || !canvasHeight || viewScale <= 0) {
return null
}
const mScale = minimapScale.value
const { minX, minY } = contentBounds.value
// 视口在画布坐标中的位置和大小
const vpX = -offsetX / viewScale
const vpY = -offsetY / viewScale
const vpW = canvasWidth / viewScale
const vpH = canvasHeight / viewScale
return {
position: 'absolute',
left: `${(vpX - minX) * mScale + MINIMAP_PADDING}px`,
top: `${(vpY - minY) * mScale + MINIMAP_PADDING}px`,
width: `${vpW * mScale}px`,
height: `${vpH * mScale}px`,
border: '2px solid var(--ed-accent)',
background: 'rgba(22, 119, 255, 0.08)',
borderRadius: '1px',
pointerEvents: 'none',
}
})
// ── 点击/拖拽导航 ──
function screenToCanvasCoords(clientX: number, clientY: number) {
if (!minimapRef.value) {
return null
}
const rect = minimapRef.value.getBoundingClientRect()
const mx = clientX - rect.left - MINIMAP_PADDING
const my = clientY - rect.top - MINIMAP_PADDING
const mScale = minimapScale.value
const { minX, minY } = contentBounds.value
// 小地图坐标 → 画布坐标
const canvasX = mx / mScale + minX
const canvasY = my / mScale + minY
return { canvasX, canvasY }
}
function panViewportTo(clientX: number, clientY: number) {
const coords = screenToCanvasCoords(clientX, clientY)
if (!coords) {
return
}
const { scale: viewScale, offsetX, offsetY, canvasWidth, canvasHeight } = canvasView.value
// 将视口中心移到点击位置,通过 viewport:panBy 事件通知 canvas-stage 同步重绘底图。
const vpW = canvasWidth / viewScale
const vpH = canvasHeight / viewScale
const targetOffsetX = -(coords.canvasX - vpW / 2) * viewScale
const targetOffsetY = -(coords.canvasY - vpH / 2) * viewScale
setEditorEmitter('viewport:panBy', {
deltaX: targetOffsetX - offsetX,
deltaY: targetOffsetY - offsetY,
})
}
function onMinimapPointerDown(event: PointerEvent) {
// 阻止事件冒泡到画布
event.stopPropagation()
event.preventDefault()
isDragging.value = true
panViewportTo(event.clientX, event.clientY)
document.addEventListener('pointermove', onDocumentPointerMove)
document.addEventListener('pointerup', onDocumentPointerUp)
}
function onDocumentPointerMove(event: PointerEvent) {
if (!isDragging.value) {
return
}
event.preventDefault()
panViewportTo(event.clientX, event.clientY)
}
function onDocumentPointerUp() {
isDragging.value = false
document.removeEventListener('pointermove', onDocumentPointerMove)
document.removeEventListener('pointerup', onDocumentPointerUp)
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
onBeforeUnmount(() => {
document.removeEventListener('pointermove', onDocumentPointerMove)
document.removeEventListener('pointerup', onDocumentPointerUp)
})
</script>
<template>
<div class="minimap-container" :class="{ 'is-collapsed': collapsed }">
<button
class="minimap-toggle"
:title="collapsed ? '显示小地图' : '隐藏小地图'"
@click.stop="toggleCollapsed"
>
<svg
v-if="collapsed"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<rect x="7" y="7" width="10" height="10" rx="1" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div
v-show="!collapsed"
ref="minimapRef"
class="minimap-panel"
@pointerdown="onMinimapPointerDown"
>
<!-- 底图区域 -->
<div class="minimap-base-layer" :style="baseLayerStyle" />
<!-- 图层矩形 -->
<div
v-for="layer in visibleLayers"
:key="layer.id"
class="minimap-layer-rect"
:style="getLayerRectStyle(layer)"
/>
<!-- 视口指示器 -->
<div
v-if="viewportStyle"
class="minimap-viewport"
:style="viewportStyle"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.minimap-container {
position: absolute;
right: var(--ed-space-5);
bottom: var(--ed-space-5);
z-index: 8;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--ed-space-2);
pointer-events: none;
}
.minimap-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--ed-border);
border-radius: var(--ed-radius-sm);
background: var(--ed-bg-raised);
color: var(--ed-text-secondary);
cursor: pointer;
pointer-events: auto;
box-shadow: var(--ed-shadow-sm);
transition: background var(--ed-transition-fast), color var(--ed-transition-fast);
&:hover {
background: var(--ed-bg-hover);
color: var(--ed-text-primary);
}
&:active {
background: var(--ed-bg-active);
}
}
.minimap-panel {
position: relative;
width: 160px;
height: 120px;
background: var(--ed-bg-surface);
border: 1px solid var(--ed-border);
border-radius: var(--ed-radius-md);
box-shadow: var(--ed-shadow-md);
overflow: hidden;
cursor: crosshair;
pointer-events: auto;
user-select: none;
touch-action: none;
}
.minimap-base-layer {
pointer-events: none;
border-radius: 0;
}
.minimap-layer-rect {
pointer-events: none;
border-radius: 0;
}
.minimap-viewport {
pointer-events: none;
}
.minimap-container.is-collapsed .minimap-panel {
display: none;
}
</style>

View File

@@ -0,0 +1,27 @@
import { inject, provide } from 'vue'
export interface PropertyContext {
/** 更新属性字段(自动处理分组/多选) */
updateField: (field: string, value: unknown) => void
/** 获取字段状态(自动处理分组混合值) */
getFieldState: (fieldKey: string) => { value: unknown, mixed: boolean }
}
// 使用字符串 key 确保 HMR 热更新时 provide/inject 不会断链
const KEY = 'ed-property-context'
export function providePropertyContext(ctx: PropertyContext) {
provide(KEY, ctx)
}
export function usePropertyContext(): PropertyContext {
const ctx = inject<PropertyContext>(KEY)
if (!ctx) {
console.warn('[usePropertyContext] inject 失败,请确保在 PropertyPanel 内使用')
return {
updateField: () => {},
getFieldState: () => ({ value: undefined, mixed: false }),
}
}
return ctx
}

View File

@@ -0,0 +1,299 @@
import { ref, shallowRef, watch } from 'vue'
import { MAX_CANVAS_SIZE, MIN_CANVAS_HEIGHT, MIN_CANVAS_WIDTH } from '@/constants'
import { useCanvasState } from '../../../context/state'
import { listenEditorEmitter, setEditorEmitter } from '../../../emitter'
import { buildGroupRectUpdates } from '../../../grouping'
import { clamp } from '../../../utils'
export function usePropertyPanel() {
const { canvasId, canvasView, selectedLayerId, selectedLayerIds, selectedLayer, selectedGroup, isBaseLayerActive } = useCanvasState()
const panelRef = shallowRef<HTMLDivElement | null>(null)
const visible = ref(false)
const interactionSuspended = ref(false)
const restoreVisibleAfterInteraction = ref(false)
const shouldRenderPanel = ref(Boolean(canvasId.value))
function normalizeLockedSizeByWidth(width: number, ratio: number) {
let nextWidth = clamp(Math.round(width), MIN_CANVAS_WIDTH, MAX_CANVAS_SIZE)
let nextHeight = Math.round(nextWidth / ratio)
if (nextHeight < MIN_CANVAS_HEIGHT) {
nextHeight = MIN_CANVAS_HEIGHT
nextWidth = Math.round(nextHeight * ratio)
}
if (nextWidth < MIN_CANVAS_WIDTH) {
nextWidth = MIN_CANVAS_WIDTH
nextHeight = Math.round(nextWidth / ratio)
}
if (nextHeight > MAX_CANVAS_SIZE) {
nextHeight = MAX_CANVAS_SIZE
nextWidth = Math.round(nextHeight * ratio)
}
if (nextWidth > MAX_CANVAS_SIZE) {
nextWidth = MAX_CANVAS_SIZE
nextHeight = Math.round(nextWidth / ratio)
}
return {
nextWidth: clamp(nextWidth, MIN_CANVAS_WIDTH, MAX_CANVAS_SIZE),
nextHeight: clamp(nextHeight, MIN_CANVAS_HEIGHT, MAX_CANVAS_SIZE),
}
}
function normalizeLockedSizeByHeight(height: number, ratio: number) {
let nextHeight = clamp(Math.round(height), MIN_CANVAS_HEIGHT, MAX_CANVAS_SIZE)
let nextWidth = Math.round(nextHeight * ratio)
if (nextWidth < MIN_CANVAS_WIDTH) {
nextWidth = MIN_CANVAS_WIDTH
nextHeight = Math.round(nextWidth / ratio)
}
if (nextHeight < MIN_CANVAS_HEIGHT) {
nextHeight = MIN_CANVAS_HEIGHT
nextWidth = Math.round(nextHeight * ratio)
}
if (nextWidth > MAX_CANVAS_SIZE) {
nextWidth = MAX_CANVAS_SIZE
nextHeight = Math.round(nextWidth / ratio)
}
if (nextHeight > MAX_CANVAS_SIZE) {
nextHeight = MAX_CANVAS_SIZE
nextWidth = Math.round(nextHeight * ratio)
}
return {
nextWidth: clamp(nextWidth, MIN_CANVAS_WIDTH, MAX_CANVAS_SIZE),
nextHeight: clamp(nextHeight, MIN_CANVAS_HEIGHT, MAX_CANVAS_SIZE),
}
}
function onPropertyUpdate(payload: { field: string, value: any }) {
const layer = selectedLayer.value
const group = selectedGroup.value
const { field, value } = payload
// 多选批量编辑(非分组)
if (!layer && !group && selectedLayerIds.value.length > 1) {
if (['x', 'y', 'width', 'height'].includes(field)) {
// 位置/尺寸批量编辑暂不支持(各图层位置不同,需要分组操作)
return
}
setEditorEmitter('layer:updateFields', {
ids: selectedLayerIds.value,
field,
value,
})
return
}
if (!layer && !group) {
if (field === 'view.background') {
if (typeof value === 'string' && value.trim()) {
canvasView.value.background = value
}
return
}
if (field === 'view.backgroundLockAspectRatio') {
canvasView.value.backgroundLockAspectRatio = Boolean(value)
return
}
if (field === 'view.imageWidth' || field === 'view.imageHeight') {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) {
return
}
const currentWidth = Math.max(MIN_CANVAS_WIDTH, Math.round(canvasView.value.imageWidth || MIN_CANVAS_WIDTH))
const currentHeight = Math.max(MIN_CANVAS_HEIGHT, Math.round(canvasView.value.imageHeight || MIN_CANVAS_HEIGHT))
let nextWidth = currentWidth
let nextHeight = currentHeight
if (canvasView.value.backgroundLockAspectRatio && currentWidth > 0 && currentHeight > 0) {
const ratio = currentWidth / currentHeight
if (field === 'view.imageWidth') {
const normalized = normalizeLockedSizeByWidth(numericValue, ratio)
nextWidth = normalized.nextWidth
nextHeight = normalized.nextHeight
}
else {
const normalized = normalizeLockedSizeByHeight(numericValue, ratio)
nextWidth = normalized.nextWidth
nextHeight = normalized.nextHeight
}
}
else if (field === 'view.imageWidth') {
nextWidth = clamp(Math.round(numericValue), MIN_CANVAS_WIDTH, MAX_CANVAS_SIZE)
}
else {
nextHeight = clamp(Math.round(numericValue), MIN_CANVAS_HEIGHT, MAX_CANVAS_SIZE)
}
setEditorEmitter('baseLayer:setSize', { width: nextWidth, height: nextHeight })
}
return
}
const hasCanvasSize = canvasView.value.imageWidth > 0 && canvasView.value.imageHeight > 0
if (group) {
if (['x', 'y', 'width', 'height'].includes(field)) {
if (!hasCanvasSize) {
return
}
const minSize = 24
let nextX = group.x
let nextY = group.y
let nextWidth = group.width
let nextHeight = group.height
if (field === 'x') {
nextX = clamp(value, 0, Math.max(0, canvasView.value.imageWidth - nextWidth))
}
if (field === 'y') {
nextY = clamp(value, 0, Math.max(0, canvasView.value.imageHeight - nextHeight))
}
if (field === 'width') {
nextWidth = clamp(value, minSize, canvasView.value.imageWidth)
nextX = clamp(nextX, 0, Math.max(0, canvasView.value.imageWidth - nextWidth))
}
if (field === 'height') {
nextHeight = clamp(value, minSize, canvasView.value.imageHeight)
nextY = clamp(nextY, 0, Math.max(0, canvasView.value.imageHeight - nextHeight))
}
setEditorEmitter('layer:updateRects', buildGroupRectUpdates(
group.layers,
group,
{
x: nextX,
y: nextY,
width: nextWidth,
height: nextHeight,
},
))
return
}
setEditorEmitter('layer:updateFields', {
ids: group.layerIds,
field,
value,
})
return
}
if (!layer) {
return
}
if (['x', 'y', 'width', 'height'].includes(field)) {
if (!hasCanvasSize) {
return
}
const minSize = 24
let nextX = layer.x
let nextY = layer.y
let nextWidth = layer.width
let nextHeight = layer.height
if (field === 'x') {
nextX = clamp(value, 0, Math.max(0, canvasView.value.imageWidth - nextWidth))
}
if (field === 'y') {
nextY = clamp(value, 0, Math.max(0, canvasView.value.imageHeight - nextHeight))
}
if (field === 'width') {
nextWidth = clamp(value, minSize, canvasView.value.imageWidth)
nextX = clamp(nextX, 0, Math.max(0, canvasView.value.imageWidth - nextWidth))
}
if (field === 'height') {
nextHeight = clamp(value, minSize, canvasView.value.imageHeight)
nextY = clamp(nextY, 0, Math.max(0, canvasView.value.imageHeight - nextHeight))
}
setEditorEmitter('layer:updateRect', {
id: layer.id,
nextX,
nextY,
nextWidth,
nextHeight,
})
return
}
setEditorEmitter('layer:updateField', {
id: layer.id,
field,
value,
})
}
function canShowPanel() {
return Boolean(canvasId.value)
}
function suspendPanel() {
if (!interactionSuspended.value) {
restoreVisibleAfterInteraction.value = visible.value
}
interactionSuspended.value = true
visible.value = false
}
listenEditorEmitter('propertyPanel:suspend', suspendPanel)
function resumePanel() {
if (!interactionSuspended.value) {
return
}
interactionSuspended.value = false
const shouldRestore = restoreVisibleAfterInteraction.value
restoreVisibleAfterInteraction.value = false
if (shouldRestore && canShowPanel()) {
visible.value = true
}
}
listenEditorEmitter('propertyPanel:resume', resumePanel)
function closePanel() {
restoreVisibleAfterInteraction.value = false
visible.value = canShowPanel()
}
listenEditorEmitter('propertyPanel:closePanel', closePanel)
function openPanel() {
if (interactionSuspended.value) {
restoreVisibleAfterInteraction.value = canShowPanel()
return
}
visible.value = canShowPanel()
}
listenEditorEmitter('propertyPanel:openPanel', openPanel)
watch(
() => [canvasId.value, selectedLayerId.value, selectedLayerIds.value.length, selectedGroup.value?.groupId ?? '', isBaseLayerActive.value],
() => {
shouldRenderPanel.value = Boolean(canvasId.value)
if (!interactionSuspended.value) {
visible.value = canShowPanel()
}
},
{ flush: 'sync' },
)
shouldRenderPanel.value = Boolean(canvasId.value)
visible.value = canShowPanel()
return {
panelRef,
visible,
shouldRenderPanel,
onPropertyUpdate,
}
}

View File

@@ -0,0 +1,175 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
interface PanelPosition {
x: number
y: number
}
const STORAGE_KEY = 'ed:property-panel-dock'
const DEFAULT_POSITION: PanelPosition = { x: -1, y: -1 }
const PANEL_WIDTH = 240
const PANEL_MIN_VISIBLE = 48
function loadPersistedState() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw) as { isDocked: boolean, position: PanelPosition }
if (typeof parsed.isDocked !== 'boolean') {
return null
}
return parsed
}
catch {
return null
}
}
function persistState(isDocked: boolean, position: PanelPosition) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ isDocked, position }))
}
catch {
// sessionStorage 不可用时静默忽略
}
}
export function usePropertyPanelDock() {
const persisted = loadPersistedState()
const isDocked = ref(persisted?.isDocked ?? true)
const position = reactive<PanelPosition>(
persisted?.position ?? { ...DEFAULT_POSITION },
)
// 拖拽状态
const isDragging = ref(false)
let dragStartX = 0
let dragStartY = 0
let dragStartPosX = 0
let dragStartPosY = 0
// 初始化浮动位置(未设置过时居中偏右)
function initDefaultPosition() {
if (position.x < 0 || position.y < 0) {
position.x = Math.max(0, window.innerWidth - PANEL_WIDTH - 60)
position.y = 80
}
}
// 将面板约束在视口内
function clampToViewport() {
const maxX = window.innerWidth - PANEL_MIN_VISIBLE
const maxY = window.innerHeight - PANEL_MIN_VISIBLE
position.x = Math.max(0, Math.min(position.x, maxX))
position.y = Math.max(0, Math.min(position.y, maxY))
}
function toggleDock() {
isDocked.value = !isDocked.value
if (!isDocked.value) {
initDefaultPosition()
clampToViewport()
}
persistState(isDocked.value, position)
}
// 拖拽处理
function onDragStart(event: MouseEvent) {
if (isDocked.value) {
return
}
// 仅左键触发
if (event.button !== 0) {
return
}
event.preventDefault()
isDragging.value = true
dragStartX = event.clientX
dragStartY = event.clientY
dragStartPosX = position.x
dragStartPosY = position.y
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
}
function onDragMove(event: MouseEvent) {
if (!isDragging.value) {
return
}
const dx = event.clientX - dragStartX
const dy = event.clientY - dragStartY
position.x = dragStartPosX + dx
position.y = dragStartPosY + dy
}
function onDragEnd() {
if (!isDragging.value) {
return
}
isDragging.value = false
clampToViewport()
persistState(isDocked.value, position)
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
}
// 浮动时的样式
const floatingStyle = computed(() => {
if (isDocked.value) {
return undefined
}
return {
position: 'fixed' as const,
left: `${position.x}px`,
top: `${position.y}px`,
zIndex: 1000,
height: 'auto',
maxHeight: '80vh',
width: `${PANEL_WIDTH}px`,
boxShadow: 'var(--ed-shadow-lg)',
borderRadius: 'var(--ed-radius-lg)',
transition: isDragging.value ? 'none' : undefined,
}
})
// 窗口 resize 时约束面板位置
function onWindowResize() {
if (!isDocked.value) {
clampToViewport()
}
}
onMounted(() => {
window.addEventListener('resize', onWindowResize)
if (!isDocked.value) {
initDefaultPosition()
clampToViewport()
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowResize)
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
})
// 状态变化时持久化
watch(
() => isDocked.value,
() => persistState(isDocked.value, position),
)
return {
isDocked,
isDragging,
position,
floatingStyle,
toggleDock,
onDragStart,
}
}

View File

@@ -0,0 +1,127 @@
import type { Component } from 'vue'
import { computed } from 'vue'
import { useCanvasState } from '../../../context/state'
import BarConfig from '../sections/BarConfig.vue'
import ButtonConfig from '../sections/ButtonConfig.vue'
import CanvasSwitcherConfig from '../sections/CanvasSwitcherConfig.vue'
import EventEditor from '../sections/EventEditor.vue'
import PidControllerConfig from '../sections/PidControllerConfig.vue'
import SectionFill from '../sections/SectionFill.vue'
import SectionGeometry from '../sections/SectionGeometry.vue'
import SectionNumberFormat from '../sections/SectionNumberFormat.vue'
import SectionShadow from '../sections/SectionShadow.vue'
import SectionStroke from '../sections/SectionStroke.vue'
import SectionText from '../sections/SectionText.vue'
import SectionTextContent from '../sections/SectionTextContent.vue'
import ValveControllerConfig from '../sections/ValveControllerConfig.vue'
export interface SectionEntry {
key: string
component: Component
defaultCollapsed?: boolean
}
const SECTION_MAP: Record<string, SectionEntry[]> = {
rect: [
{ key: 'geometry', component: SectionGeometry },
{ key: 'fill', component: SectionFill },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
{ key: 'shadow', component: SectionShadow, defaultCollapsed: true },
{ key: 'event', component: EventEditor, defaultCollapsed: true },
],
number: [
{ key: 'geometry', component: SectionGeometry },
{ key: 'numberFormat', component: SectionNumberFormat },
{ key: 'text', component: SectionText },
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
{ key: 'shadow', component: SectionShadow, defaultCollapsed: true },
{ key: 'event', component: EventEditor, defaultCollapsed: true },
],
text: [
{ key: 'geometry', component: SectionGeometry },
{ key: 'textContent', component: SectionTextContent },
{ key: 'text', component: SectionText },
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
{ key: 'shadow', component: SectionShadow, defaultCollapsed: true },
{ key: 'event', component: EventEditor, defaultCollapsed: true },
],
bar: [
{ key: 'geometry', component: SectionGeometry },
{ key: 'barConfig', component: BarConfig },
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
{ key: 'event', component: EventEditor, defaultCollapsed: true },
],
button: [
{ key: 'geometry', component: SectionGeometry },
{ key: 'buttonConfig', component: ButtonConfig },
{ key: 'text', component: SectionText },
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
{ key: 'event', component: EventEditor },
],
pidController: [
{ key: 'geometry', component: SectionGeometry },
{ key: 'pidControllerConfig', component: PidControllerConfig },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
],
valveController: [
{ key: 'geometry', component: SectionGeometry },
{ key: 'valveControllerConfig', component: ValveControllerConfig },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
],
canvasSwitcher: [
{ key: 'geometry', component: SectionGeometry },
{ key: 'canvasSwitcherConfig', component: CanvasSwitcherConfig },
{ key: 'fill', component: SectionFill, defaultCollapsed: true },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
{ key: 'event', component: EventEditor, defaultCollapsed: true },
],
}
const FALLBACK_SECTIONS: SectionEntry[] = [
{ key: 'geometry', component: SectionGeometry },
{ key: 'fill', component: SectionFill },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
{ key: 'shadow', component: SectionShadow, defaultCollapsed: true },
{ key: 'event', component: EventEditor, defaultCollapsed: true },
]
// 多选不同类型时使用公共 Sectiongeometry + fill + stroke
const MULTI_SELECT_SECTIONS: SectionEntry[] = [
{ key: 'geometry', component: SectionGeometry },
{ key: 'fill', component: SectionFill },
{ key: 'stroke', component: SectionStroke, defaultCollapsed: true },
]
export function usePropertySections() {
const { selectedLayer, selectedGroup, selectedLayerIds } = useCanvasState()
const sections = computed<SectionEntry[]>(() => {
// 多选(非分组)时显示公共属性面板
if (!selectedLayer.value && !selectedGroup.value && selectedLayerIds.value.length > 1) {
return MULTI_SELECT_SECTIONS
}
// 分组选择:按成员类型计算公共 Sections
if (selectedGroup.value) {
const types = [...new Set(selectedGroup.value.layers.map(l => l.type))]
if (types.length === 1) {
return SECTION_MAP[types[0]] ?? FALLBACK_SECTIONS
}
return MULTI_SELECT_SECTIONS
}
if (!selectedLayer.value) {
return []
}
const type = selectedLayer.value.type
return SECTION_MAP[type] ?? FALLBACK_SECTIONS
})
return { sections }
}

View File

@@ -0,0 +1,898 @@
<script setup lang="ts">
import type { CanvasView } from '../../types'
import { get } from 'es-toolkit/compat'
import { computed, ref, shallowRef, watch } from 'vue'
import { useCanvasState } from '../../context/state'
import { setEditorEmitter } from '../../emitter'
import { getCommonLayerFieldValue, isMixedGroupFieldValue } from '../../grouping'
import { providePropertyContext } from './composables/usePropertyContext'
import { usePropertyPanel } from './composables/usePropertyPanel'
import { usePropertyPanelDock } from './composables/usePropertyPanelDock'
import { usePropertySections } from './composables/usePropertySections'
import SectionDataBinding from './sections/SectionDataBinding.vue'
type BaseLayerDraftField = 'width' | 'height'
const LEADING_HASH_RE = /^#/
const NON_HEX_COLOR_RE = /[^0-9a-f]/gi
const { canvasView, selectedLayer, selectedGroup, selectedLayerIds } = useCanvasState()
const { panelRef, visible, shouldRenderPanel, onPropertyUpdate } = usePropertyPanel()
const { sections } = usePropertySections()
const { isDocked, isDragging, floatingStyle, toggleDock, onDragStart } = usePropertyPanelDock()
providePropertyContext({
updateField: (field, value) => onPropertyUpdate({ field, value }),
getFieldState: getSelectionFieldState,
})
const replaceBackgroundInputRef = shallowRef<HTMLInputElement | null>(null)
const baseLayerWidthDraft = ref('')
const baseLayerHeightDraft = ref('')
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('底图读取失败'))
reader.readAsDataURL(file)
})
}
function openBackgroundPicker() {
replaceBackgroundInputRef.value?.click()
}
async function onReplaceBackgroundChange(event: Event) {
const target = event.target as HTMLInputElement | null
const file = target?.files?.[0]
if (!file) {
return
}
try {
const source = await readFileAsDataUrl(file)
if (source) {
setEditorEmitter('propertyPanel:replaceBackground', { source })
}
}
catch (error) {
console.error('[property-panel] failed to replace background', error)
}
finally {
if (target) {
target.value = ''
}
}
}
function getSelectionFieldState(fieldKey: string) {
// 统一抽象"单图层 / 分组 / 底图"三种来源
if (selectedGroup.value) {
if (fieldKey === 'x' || fieldKey === 'y' || fieldKey === 'width' || fieldKey === 'height') {
return {
value: selectedGroup.value[fieldKey as 'x' | 'y' | 'width' | 'height'],
mixed: false,
}
}
const commonValue = getCommonLayerFieldValue(selectedGroup.value.layers, fieldKey)
return {
value: isMixedGroupFieldValue(commonValue) ? '' : commonValue,
mixed: isMixedGroupFieldValue(commonValue),
}
}
if (selectedLayer.value) {
return {
value: get(selectedLayer.value, fieldKey),
mixed: false,
}
}
if (fieldKey.startsWith('view.')) {
const key = fieldKey.replace('view.', '')
if (key in canvasView.value) {
return {
value: canvasView.value[key as keyof CanvasView],
mixed: false,
}
}
}
return {
value: '',
mixed: false,
}
}
function getNumericValue(fieldKey: string, fallback = 0) {
const state = getSelectionFieldState(fieldKey)
if (state.mixed) {
return {
value: fallback,
mixed: true,
}
}
const number = typeof state.value === 'number' ? state.value : Number(state.value)
return {
value: Number.isFinite(number) ? number : fallback,
mixed: false,
}
}
function getIntegerInputValue(fieldKey: string, fallback = 0) {
const state = getNumericValue(fieldKey, fallback)
return state.mixed ? '' : String(Math.round(state.value))
}
function getStringInputValue(fieldKey: string, fallback = '') {
const state = getSelectionFieldState(fieldKey)
if (state.mixed) {
return ''
}
if (typeof state.value === 'string') {
return state.value.trim() ? state.value : fallback
}
if (state.value === null || state.value === undefined || state.value === '') {
return fallback
}
return String(state.value)
}
function syncBaseLayerDraft() {
baseLayerWidthDraft.value = getIntegerInputValue('view.imageWidth', 0)
baseLayerHeightDraft.value = getIntegerInputValue('view.imageHeight', 0)
}
function onBaseLayerSizeCommit(field: BaseLayerDraftField) {
const rawValue = (field === 'width' ? baseLayerWidthDraft.value : baseLayerHeightDraft.value).trim()
if (!rawValue) {
syncBaseLayerDraft()
return
}
const parsedValue = Number.parseFloat(rawValue)
if (Number.isNaN(parsedValue)) {
syncBaseLayerDraft()
return
}
onPropertyUpdate({
field: field === 'width' ? 'view.imageWidth' : 'view.imageHeight',
value: Math.round(parsedValue),
})
}
const isMultiSelect = computed(() => !selectedLayer.value && !selectedGroup.value && selectedLayerIds.value.length > 1)
const hasSelectedLayer = computed(() => Boolean(selectedLayer.value || selectedGroup.value || isMultiSelect.value))
const showBaseLayerPanel = computed(() => visible.value && !hasSelectedLayer.value)
const showEmptyState = computed(() => !visible.value && !hasSelectedLayer.value)
const hasCanvasSize = computed(() => canvasView.value.imageWidth > 0 && canvasView.value.imageHeight > 0)
const hasBackgroundImage = computed(() => canvasView.value.imageNaturalWidth > 0 && canvasView.value.imageNaturalHeight > 0)
const isBaseLayerAspectLocked = computed(() => canvasView.value.backgroundLockAspectRatio !== false)
const baseLayerBackgroundInputValue = computed(() => getStringInputValue('view.background', '#FFFFFF').replace(LEADING_HASH_RE, '').toUpperCase())
const selectionSummary = computed(() => {
if (isMultiSelect.value) {
return `已选择 ${selectedLayerIds.value.length} 个图层`
}
if (!selectedGroup.value) {
return ''
}
return `${selectedGroup.value.groupName} · ${selectedGroup.value.layerIds.length} 个图层`
})
watch(
() => [selectedLayer.value, selectedGroup.value?.groupId ?? '', canvasView.value.imageWidth, canvasView.value.imageHeight],
() => {
if (!selectedLayer.value && !selectedGroup.value) {
syncBaseLayerDraft()
}
},
{ immediate: true },
)
function toggleBaseLayerAspectLock() {
onPropertyUpdate({
field: 'view.backgroundLockAspectRatio',
value: !isBaseLayerAspectLocked.value,
})
}
function onRemoveBackground() {
setEditorEmitter('propertyPanel:removeBackground')
}
function onBackgroundColorInput(event: Event) {
const target = event.target as HTMLInputElement | null
const value = target?.value?.trim()
if (!value) {
return
}
onPropertyUpdate({
field: 'view.background',
value,
})
}
function onBackgroundTextInput(value: string) {
const normalized = value.trim().replace(LEADING_HASH_RE, '').replace(NON_HEX_COLOR_RE, '').toUpperCase()
onPropertyUpdate({
field: 'view.background',
value: normalized ? `#${normalized}` : '',
})
}
</script>
<template>
<aside
v-if="shouldRenderPanel"
ref="panelRef"
class="property-panel-shell"
:class="{
'property-panel-shell--floating': !isDocked,
'property-panel-shell--dragging': isDragging,
}"
:style="floatingStyle"
@click.stop
>
<section class="property-panel" :class="{ 'property-panel--floating': !isDocked }">
<header
class="property-header"
:class="{ 'property-header--draggable': !isDocked }"
@mousedown="onDragStart"
>
<div class="title-block">
<span class="title">属性</span>
<span v-if="selectionSummary" class="selection-summary">{{ selectionSummary }}</span>
<span v-else-if="showBaseLayerPanel" class="selection-summary">背景图层</span>
<span v-else class="selection-summary">选择单个图层后可编辑属性</span>
</div>
<div class="header-actions">
<button
type="button"
class="dock-toggle-btn"
:title="isDocked ? '浮动面板' : '停靠面板'"
@mousedown.stop
@click.stop="toggleDock"
>
<i class="fa-solid" :class="isDocked ? 'fa-up-right-and-down-left-from-center' : 'fa-down-left-and-up-right-to-center'" />
</button>
</div>
</header>
<!-- 普通图层面板动态 Section 列表 -->
<div v-if="hasSelectedLayer" class="property-body">
<!-- 多选批量编辑提示 -->
<div v-if="isMultiSelect" class="multi-select-hint">
修改以下属性将应用到所有选中图层
</div>
<!-- 动态 Section 列表 -->
<component
:is="entry.component"
v-for="entry in sections"
:key="entry.key"
/>
<!-- 数据绑定仅单选/分组时显示pidController/valveController 不需要 -->
<SectionDataBinding v-if="!isMultiSelect && selectedLayer?.type !== 'pidController' && selectedLayer?.type !== 'valveController'" />
</div>
<!-- 底图面板 -->
<div v-else-if="showBaseLayerPanel" class="property-body">
<section class="section">
<div class="section-title-row">
<span class="base-layer-section-title">基础图层</span>
<input
ref="replaceBackgroundInputRef"
class="base-layer-file-input"
type="file"
accept="image/*"
@change="onReplaceBackgroundChange"
>
</div>
<div v-if="hasCanvasSize" class="base-layer-stack">
<div class="base-layer-size-row">
<div class="geo-cell">
<span class="geo-key">W</span>
<el-input
v-model="baseLayerWidthDraft"
class="geo-input"
@keyup.enter="onBaseLayerSizeCommit('width')"
@blur="onBaseLayerSizeCommit('width')"
/>
</div>
<div class="geo-cell">
<span class="geo-key">H</span>
<el-input
v-model="baseLayerHeightDraft"
class="geo-input"
@keyup.enter="onBaseLayerSizeCommit('height')"
@blur="onBaseLayerSizeCommit('height')"
/>
</div>
<button
type="button"
class="base-layer-lock-btn base-layer-lock-btn--icon"
:class="{ active: isBaseLayerAspectLocked }"
title="锁定宽高比"
@click="toggleBaseLayerAspectLock"
>
<i class="fa-solid" :class="isBaseLayerAspectLocked ? 'fa-lock' : 'fa-lock-open'" />
</button>
</div>
<div class="base-layer-fill">
<div class="section-title-row">
<span class="base-layer-fill-title">填充</span>
</div>
<div class="base-layer-fill-row base-layer-fill-row--color">
<div class="base-layer-bg-control">
<input
type="color"
class="base-layer-color-picker"
:value="getStringInputValue('view.background', '#FFFFFF')"
title="背景色"
aria-label="背景色"
@input="onBackgroundColorInput"
>
<el-input
class="base-layer-bg-input"
:model-value="baseLayerBackgroundInputValue"
@update:model-value="onBackgroundTextInput"
/>
</div>
</div>
<div class="base-layer-fill-row">
<div class="base-layer-image-label" title="图片填充" aria-label="图片填充">
<span class="base-layer-image-icon">
<i class="fa-solid fa-image" />
</span>
<span class="base-layer-image-text">图片填充</span>
</div>
<div class="base-layer-actions">
<button type="button" class="base-layer-action-link" @click="openBackgroundPicker">
{{ hasBackgroundImage ? '替换' : '设置' }}
</button>
<button v-if="hasBackgroundImage" type="button" class="base-layer-action-link base-layer-action-link--danger" @click="onRemoveBackground">
移除
</button>
</div>
</div>
</div>
</div>
<div v-else class="empty-tips">
请先设置底图再设置背景图层属性
</div>
</section>
</div>
<!-- 空状态 -->
<div v-else-if="showEmptyState" class="property-empty">
<i class="fa-solid fa-sliders" />
<span>请选择图层分组或底图后查看属性</span>
</div>
</section>
</aside>
</template>
<style lang="scss" scoped>
.property-panel-shell {
z-index: 8;
display: flex;
height: 100%;
flex: 0 0 auto;
width: var(--ed-sidebar-width);
font-family: var(--ed-font);
}
.property-panel-shell--floating {
flex: 0 0 0;
width: 0;
overflow: visible;
}
.property-panel-shell--dragging {
user-select: none;
}
.property-panel-shell button,
.property-panel-shell input,
.property-panel-shell textarea,
.property-panel-shell select {
font-family: inherit;
}
.property-panel {
width: var(--ed-sidebar-width);
height: 100%;
border-left: 1px solid var(--ed-border);
background: var(--ed-bg-surface);
display: flex;
flex-direction: column;
overflow: hidden;
}
.property-panel--floating {
height: auto;
max-height: 80vh;
border-left: none;
border: 1px solid var(--ed-border);
border-radius: var(--ed-radius-lg);
overflow: hidden;
}
.property-header {
min-height: 48px;
padding: 10px 12px;
border-bottom: 1px solid var(--ed-border);
background: var(--ed-bg-surface);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.property-header--draggable {
cursor: grab;
}
.property-panel-shell--dragging .property-header--draggable {
cursor: grabbing;
}
.header-actions {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.dock-toggle-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: var(--ed-radius-sm);
color: var(--ed-text-tertiary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
transition: color var(--ed-transition-fast), background var(--ed-transition-fast);
}
.dock-toggle-btn:hover {
background: var(--ed-bg-hover);
color: var(--ed-text-primary);
}
.title {
font-size: var(--ed-font-md);
line-height: 1;
font-weight: var(--ed-font-weight-semibold);
color: var(--ed-text-primary);
}
.title-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.selection-summary {
font-size: var(--ed-font-sm);
color: var(--ed-text-tertiary);
}
.property-body {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
padding-bottom: 24px;
}
.property-body::-webkit-scrollbar {
display: none;
}
.property-empty {
flex: 1;
min-height: 0;
padding: 24px 18px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
color: var(--ed-text-tertiary);
font-size: var(--ed-font-sm);
line-height: 1.6;
}
.property-empty i {
font-size: 16px;
color: var(--ed-text-disabled);
}
.section {
padding: var(--ed-space-5) var(--ed-space-4);
border-bottom: 1px solid var(--ed-border);
}
.section:last-child {
border-bottom: none;
}
.base-layer-file-input {
display: none;
}
.base-layer-stack {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.base-layer-size-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px;
gap: 6px;
}
.geo-cell {
min-height: 28px;
border-radius: 6px;
border: none;
background: var(--ed-bg-surface);
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 0 8px;
}
.geo-key {
font-size: 12px;
font-weight: 500;
color: var(--ed-text-tertiary);
}
.geo-input {
width: 100%;
}
.base-layer-lock-btn {
height: 28px;
min-width: 20px;
border-radius: 0;
border: none;
background: transparent;
color: var(--ed-text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
}
.base-layer-lock-btn--icon {
padding: 0;
}
.base-layer-lock-btn:hover {
background: transparent;
color: var(--ed-text-primary);
}
.base-layer-lock-btn.active {
color: var(--ed-text-primary);
background: transparent;
}
.base-layer-actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.base-layer-action-link {
border: none;
background: transparent;
padding: 0;
font-size: 11px;
line-height: 1.2;
font-weight: 500;
color: var(--ed-text-secondary);
cursor: pointer;
}
.base-layer-action-link:hover {
color: var(--ed-text-primary);
}
.base-layer-action-link--danger {
color: var(--ed-danger);
}
.base-layer-bg-control {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.base-layer-fill {
display: flex;
flex-direction: column;
gap: 8px;
}
.base-layer-fill-row {
min-height: 28px;
border-radius: 6px;
border: none;
background: var(--ed-bg-surface);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
}
.base-layer-fill-row--color {
padding-left: 10px;
}
.base-layer-color-picker {
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
flex-shrink: 0;
border: none;
border-radius: 5px;
background: transparent;
cursor: pointer;
padding: 0;
appearance: none;
-webkit-appearance: none;
}
.base-layer-color-picker::-webkit-color-swatch-wrapper {
padding: 0;
}
.base-layer-color-picker::-webkit-color-swatch {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 5px;
}
.base-layer-bg-input {
flex: 1;
}
.base-layer-image-label {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.base-layer-image-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex: 0 0 20px;
font-size: 16px;
color: var(--ed-text-tertiary);
}
.base-layer-image-text {
font-size: 12px;
color: var(--ed-text-tertiary);
white-space: nowrap;
}
.multi-select-hint {
padding: 8px 12px;
font-size: 11px;
color: var(--ed-accent, #1677ff);
background: rgba(22, 119, 255, 0.06);
border-bottom: 1px solid var(--ed-border);
}
.empty-tips {
margin-top: 8px;
border-radius: 6px;
border: 1px solid var(--ed-border);
padding: 10px;
background: var(--ed-bg-surface);
font-size: 12px;
color: var(--ed-text-tertiary);
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
font-weight: 600;
color: var(--ed-text-primary);
}
.base-layer-section-title,
.base-layer-fill-title {
font-size: 12px;
line-height: 1.1;
font-weight: 600;
color: var(--ed-text-primary);
}
.title-actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.ghost-icon-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 6px;
color: var(--ed-text-secondary);
cursor: pointer;
}
.ghost-icon-btn:hover {
background: var(--ed-bg-hover);
}
.row {
margin-top: 8px;
display: grid;
gap: 8px;
> * {
min-width: 0;
}
}
.two-cols {
grid-template-columns: 1fr 1fr;
align-items: center;
}
:deep(.el-input__wrapper) {
box-shadow: none;
background: transparent;
padding: 0;
}
:deep(.el-input__inner) {
height: 24px;
font-size: 12px;
color: var(--ed-text-primary);
}
:deep(.geo-input .el-input__inner) {
text-align: left;
font-size: 12px;
font-weight: 500;
}
:deep(.base-layer-bg-input .el-input__inner) {
font-size: 12px;
font-weight: 500;
}
:deep(.geo-input .el-input__wrapper),
:deep(.base-layer-bg-input .el-input__wrapper) {
padding: 0;
min-width: 0;
}
:deep(.el-select__wrapper) {
min-height: var(--ed-control-height);
height: var(--ed-control-height);
border-radius: var(--ed-radius-sm);
background: var(--ed-bg-surface);
border: none;
box-shadow: none;
padding: 0 6px;
font-size: var(--ed-font-sm);
}
:deep(.el-select__placeholder),
:deep(.el-select__selected-item) {
font-size: var(--ed-font-sm);
}
:deep(.el-cascader) {
width: 100%;
line-height: var(--ed-control-height);
}
:deep(.el-cascader .el-input__wrapper) {
min-height: var(--ed-control-height);
height: var(--ed-control-height);
border-radius: var(--ed-radius-sm);
background: var(--ed-bg-surface);
border: none;
padding: 0 6px;
font-size: var(--ed-font-sm);
}
:deep(.el-input-number.is-controls-right) {
height: var(--ed-control-height);
line-height: var(--ed-control-height);
}
:deep(.el-input-number.is-controls-right .el-input__wrapper) {
background: var(--ed-bg-surface);
border: none;
border-radius: var(--ed-radius-sm);
padding-right: 20px;
padding-left: 4px;
}
:deep(.el-input-number.is-controls-right .el-input__inner) {
font-size: var(--ed-font-sm);
}
:deep(.el-input-number .el-input-number__decrease),
:deep(.el-input-number .el-input-number__increase) {
width: 18px;
font-size: 10px;
}
@media (max-width: 1280px) {
.two-cols {
grid-template-columns: 1fr;
}
.base-layer-fill-row {
height: auto;
align-items: flex-start;
flex-direction: column;
padding: 12px;
}
.base-layer-bg-control {
width: 100%;
}
}
@media (max-width: 1024px) {
.title {
font-size: 16px;
}
.section-title-row {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdSection } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { setEditorEmitter } from '../../../emitter'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer, selectedGroup } = useCanvasState()
const { updateField: ctxUpdateField } = usePropertyContext()
const config = computed(() => selectedLayer.value?.config ?? {})
function updateField(field: string, value: unknown) {
ctxUpdateField(`config.${field}`, value)
}
const direction = computed({
get: () => config.value.direction ?? 'vertical',
set: (v: string) => {
updateField('direction', v)
// 切换方向时自动交换每个图层宽高
if (selectedGroup.value) {
for (const layer of selectedGroup.value.layers) {
setEditorEmitter('layer:updateField', { id: layer.id, field: 'width', value: layer.height })
setEditorEmitter('layer:updateField', { id: layer.id, field: 'height', value: layer.width })
}
}
else if (selectedLayer.value) {
const layer = selectedLayer.value
setEditorEmitter('layer:updateField', { id: layer.id, field: 'width', value: layer.height })
setEditorEmitter('layer:updateField', { id: layer.id, field: 'height', value: layer.width })
}
},
})
const minVal = computed({
get: () => config.value.min ?? 0,
set: (v: number) => updateField('min', v),
})
const maxVal = computed({
get: () => config.value.max ?? 100,
set: (v: number) => updateField('max', v),
})
const showValue = computed({
get: () => config.value.showValue ?? true,
set: (v: boolean) => updateField('showValue', v),
})
const foregroundColor = computed({
get: () => config.value.foregroundColor ?? '#00ff00',
set: (v: string) => updateField('foregroundColor', v),
})
const valueColor = computed({
get: () => config.value.valueColor ?? '#ffffff',
set: (v: string) => updateField('valueColor', v),
})
const valueFontSize = computed({
get: () => config.value.valueFontSize ?? 14,
set: (v: number) => updateField('valueFontSize', v),
})
const valueFontWeight = computed({
get: () => config.value.valueFontWeight ?? 600,
set: (v: number) => updateField('valueFontWeight', v),
})
const valueContent = computed({
get: () => config.value.valueContent ?? '',
set: (v: string) => updateField('valueContent', v || undefined),
})
const colors = computed<Array<{ threshold: number, color: string }>>(() => config.value.colors ?? [])
function addColor() {
const next = [...colors.value, { threshold: 0, color: '#1677ff' }]
updateField('colors', next)
}
function removeColor(index: number) {
const next = colors.value.filter((_, i) => i !== index)
updateField('colors', next)
}
function updateColor(index: number, field: 'threshold' | 'color', value: number | string) {
const next = colors.value.map((item, i) =>
i === index ? { ...item, [field]: value } : item,
)
updateField('colors', next)
}
</script>
<template>
<EdSection title="棒图配置">
<div class="config-row">
<span class="config-label">方向</span>
<el-radio-group v-model="direction" size="small">
<el-radio-button value="vertical">
垂直
</el-radio-button>
<el-radio-button value="horizontal">
水平
</el-radio-button>
</el-radio-group>
</div>
<div class="config-row">
<span class="config-label">最小值</span>
<el-input-number v-model="minVal" size="small" :controls="false" class="config-input" />
</div>
<div class="config-row">
<span class="config-label">最大值</span>
<el-input-number v-model="maxVal" size="small" :controls="false" class="config-input" />
</div>
<div class="config-row">
<span class="config-label">前景色</span>
<el-color-picker
v-model="foregroundColor"
size="small"
@update:model-value="(v: string | null) => foregroundColor = v ?? '#00ff00'"
/>
</div>
<div class="config-section">
<div class="config-row">
<span class="config-label">阈值颜色</span>
<el-button size="small" text type="primary" @click="addColor">
+ 添加
</el-button>
</div>
<div v-for="(item, index) in colors" :key="index" class="color-row">
<el-input-number
:model-value="item.threshold"
size="small"
:controls="false"
class="threshold-input"
@update:model-value="(v: number | undefined) => updateColor(index, 'threshold', v ?? 0)"
/>
<el-color-picker
:model-value="item.color"
size="small"
@update:model-value="(v: string | null) => updateColor(index, 'color', v ?? '#00ff00')"
/>
<el-button size="small" text type="danger" @click="removeColor(index)">
<i class="fa-solid fa-trash-can" />
</el-button>
</div>
</div>
</EdSection>
<EdSection title="数值配置">
<div class="config-row">
<span class="config-label">显示数值</span>
<el-switch v-model="showValue" size="small" />
</div>
<template v-if="showValue">
<div class="config-row">
<span class="config-label">数值内容</span>
<el-input
v-model="valueContent"
size="small"
placeholder="默认百分比"
clearable
class="config-input"
/>
</div>
<div class="config-row">
<span class="config-label">字体颜色</span>
<el-color-picker
v-model="valueColor"
size="small"
@update:model-value="(v: string | null) => valueColor = v ?? '#ffffff'"
/>
</div>
<div class="config-row">
<span class="config-label">字号</span>
<el-input-number v-model="valueFontSize" size="small" :min="10" :max="72" :controls="false" class="config-input" />
</div>
<div class="config-row">
<span class="config-label">字重</span>
<el-select v-model="valueFontWeight" size="small" class="config-input">
<el-option :value="300" label="细体" />
<el-option :value="400" label="常规" />
<el-option :value="500" label="中等" />
<el-option :value="600" label="半粗" />
<el-option :value="700" label="粗体" />
</el-select>
</div>
</template>
</EdSection>
</template>
<style lang="scss" scoped>
.config-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.config-label {
font-size: 12px;
color: #606266;
flex-shrink: 0;
width: 56px;
}
.config-input { width: 100px; }
.config-section { margin-top: 4px; }
.color-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
padding-left: 56px;
}
.threshold-input { width: 80px; }
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdSection } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer, canvasList } = useCanvasState()
const { updateField: ctxUpdateField } = usePropertyContext()
const config = computed(() => selectedLayer.value?.config ?? {})
function updateField(field: string, value: unknown) {
ctxUpdateField(`config.${field}`, value)
}
const label = computed({
get: () => config.value.label ?? '按钮',
set: (v: string) => updateField('label', v),
})
const buttonType = computed({
get: () => config.value.buttonType ?? 'trigger',
set: (v: string) => updateField('buttonType', v),
})
const targetCanvas = computed({
get: () => config.value.targetCanvas ?? '',
set: (v: string) => updateField('targetCanvas', v),
})
const confirmRequired = computed({
get: () => config.value.confirmRequired ?? false,
set: (v: boolean) => updateField('confirmRequired', v),
})
const confirmMessage = computed({
get: () => config.value.confirmMessage ?? '确认执行此操作?',
set: (v: string) => updateField('confirmMessage', v),
})
const canvasOptions = computed(() =>
(canvasList.value ?? []).map(c => ({ label: c.name, value: c.id })),
)
const buttonTypeOptions = [
{ label: '触发', value: 'trigger' },
{ label: '切换', value: 'toggle' },
{ label: '导航', value: 'navigate' },
]
</script>
<template>
<EdSection title="按钮配置">
<div class="config-row">
<span class="config-label">按钮文字</span>
<el-input v-model="label" size="small" class="config-input" />
</div>
<div class="config-row">
<span class="config-label">按钮类型</span>
<el-select v-model="buttonType" size="small" class="config-input">
<el-option v-for="opt in buttonTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</div>
<div v-if="buttonType === 'navigate'" class="config-row">
<span class="config-label">目标画布</span>
<el-select v-model="targetCanvas" size="small" class="config-input" placeholder="选择画布">
<el-option v-for="opt in canvasOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</div>
<div class="config-row">
<span class="config-label">二次确认</span>
<el-switch v-model="confirmRequired" size="small" />
</div>
<div v-if="confirmRequired" class="config-row">
<span class="config-label">确认提示</span>
<el-input v-model="confirmMessage" size="small" class="config-input" />
</div>
</EdSection>
</template>
<style lang="scss" scoped>
.config-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.config-label {
font-size: 12px;
color: #606266;
flex-shrink: 0;
width: 56px;
}
.config-input {
flex: 1;
max-width: 180px;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdColorInput, EdSection } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer, canvasList } = useCanvasState()
const { updateField } = usePropertyContext()
const items = computed<Array<{ canvasId: string }>>(() => selectedLayer.value?.config?.items ?? [])
const activeColor = computed(() => String(selectedLayer.value?.config?.activeColor ?? '#E1E1E1'))
const inactiveColor = computed(() => String(selectedLayer.value?.config?.inactiveColor ?? '#C8C8C8'))
const activeTextColor = computed(() => String(selectedLayer.value?.config?.activeTextColor ?? '#333333'))
const inactiveTextColor = computed(() => String(selectedLayer.value?.config?.inactiveTextColor ?? '#333333'))
const canvasOptions = computed(() => (canvasList.value ?? []).map(c => ({ label: c.name, value: c.id })))
function addItem() {
updateField('config.items', [...items.value, { canvasId: '' }])
}
function removeItem(index: number) {
updateField('config.items', items.value.filter((_, i) => i !== index))
}
function updateItemCanvas(index: number, canvasId: string) {
updateField('config.items', items.value.map((item, i) => i === index ? { ...item, canvasId } : item))
}
function moveItem(from: number, to: number) {
if (to < 0 || to >= items.value.length)
return
const next = [...items.value]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
updateField('config.items', next)
}
</script>
<template>
<EdSection title="切页按钮">
<div class="cg-items">
<div v-for="(item, index) in items" :key="index" class="cg-item">
<div class="cg-item-header">
<span class="cg-item-index">#{{ index + 1 }}</span>
<div class="cg-item-actions">
<el-button size="small" text :disabled="index === 0" @click="moveItem(index, index - 1)">
<i class="fa-solid fa-arrow-up" />
</el-button>
<el-button size="small" text :disabled="index === items.length - 1" @click="moveItem(index, index + 1)">
<i class="fa-solid fa-arrow-down" />
</el-button>
<el-button size="small" text type="danger" @click="removeItem(index)">
<i class="fa-solid fa-trash-can" />
</el-button>
</div>
</div>
<div class="cg-item-body">
<div class="cg-field">
<span class="cg-label">画布</span>
<el-select :model-value="item.canvasId" size="small" placeholder="选择画布" @update:model-value="(v: string) => updateItemCanvas(index, v)">
<el-option v-for="opt in canvasOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</div>
</div>
</div>
</div>
<el-button size="small" type="primary" text class="cg-add-btn" @click="addItem">
+ 添加切换项
</el-button>
<div class="cg-colors">
<div class="cg-color-row">
<span class="cg-label">激活色</span>
<EdColorInput :model-value="activeColor" @update:model-value="updateField('config.activeColor', $event)" />
</div>
<div class="cg-color-row">
<span class="cg-label">默认色</span>
<EdColorInput :model-value="inactiveColor" @update:model-value="updateField('config.inactiveColor', $event)" />
</div>
<div class="cg-color-row">
<span class="cg-label">激活文字</span>
<EdColorInput :model-value="activeTextColor" @update:model-value="updateField('config.activeTextColor', $event)" />
</div>
<div class="cg-color-row">
<span class="cg-label">默认文字</span>
<EdColorInput :model-value="inactiveTextColor" @update:model-value="updateField('config.inactiveTextColor', $event)" />
</div>
</div>
</EdSection>
</template>
<style scoped>
.cg-items { display: flex; flex-direction: column; gap: 8px; }
.cg-item { border: 1px solid #f0f0f0; border-radius: 4px; padding: 8px; }
.cg-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.cg-item-index { font-size: 11px; color: #8c8c8c; font-weight: 600; }
.cg-item-actions { display: flex; gap: 2px; }
.cg-item-body { display: flex; flex-direction: column; gap: 6px; }
.cg-field { display: flex; align-items: center; gap: 8px; }
.cg-label { font-size: 12px; color: #606266; min-width: 48px; flex-shrink: 0; }
.cg-add-btn { width: 100%; margin-top: 4px; }
.cg-colors { display: flex; flex-direction: column; gap: var(--ed-space-4); margin-top: 12px; border-top: 1px solid #f0f0f0; padding-top: 8px; }
.cg-color-row { display: flex; align-items: center; gap: 8px; }
</style>

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
import type { ComponentEvent } from '../../../types'
import { computed, ref, watch } from 'vue'
import { generateUUID } from '@/utils'
const props = defineProps<{
visible: boolean
isEditing: boolean
editingEvent?: ComponentEvent | null
canvasOptions: { label: string, value: string }[]
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'save', data: ComponentEvent): void
}>()
const dialogVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value),
})
// 表单状态
const eventId = ref('')
const trigger = ref<'click' | 'dblclick'>('click')
const condition = ref('')
const actionType = ref('callMethod')
const actionPayload = ref<Record<string, unknown>>({})
const triggerOptions = [
{ label: '单击', value: 'click' },
{ label: '双击', value: 'dblclick' },
]
const actionTypeOptions = [
{ label: '调用方法', value: 'callMethod' },
{ label: '切换画布', value: 'navigateCanvas' },
]
const dialogTitle = computed(() => props.isEditing ? '编辑事件' : '添加事件')
// 初始化表单
watch(() => props.visible, (v) => {
if (v) {
const e = props.editingEvent
if (e) {
eventId.value = e.id
trigger.value = e.trigger
condition.value = e.condition ?? ''
actionType.value = e.action.type
actionPayload.value = e.action.payload ? { ...e.action.payload } : {}
}
else {
eventId.value = generateUUID()
trigger.value = 'click'
condition.value = ''
actionType.value = 'callMethod'
actionPayload.value = {}
}
}
})
// 切换动作类型时清空 payload
watch(actionType, () => {
actionPayload.value = {}
})
const saveDisabled = computed(() => {
if (actionType.value === 'navigateCanvas' && !actionPayload.value.canvasId)
return true
return false
})
// 预览文本
const triggerLabels: Record<string, string> = { click: '单击', dblclick: '双击' }
const actionLabels: Record<string, string> = { callMethod: '调用方法', navigateCanvas: '切换画布' }
const previewText = computed(() => {
const parts: string[] = []
parts.push(triggerLabels[trigger.value] ?? trigger.value)
parts.push('→')
parts.push(actionLabels[actionType.value] ?? actionType.value)
if (actionType.value === 'callMethod' && actionPayload.value.method) {
parts.push(`(${actionPayload.value.method})`)
}
else if (actionType.value === 'navigateCanvas' && actionPayload.value.canvasId) {
const name = props.canvasOptions.find(c => c.value === actionPayload.value.canvasId)?.label
if (name)
parts.push(`(${name})`)
}
if (condition.value)
parts.push(`| 条件: ${condition.value}`)
return parts.join(' ')
})
function onSave() {
emit('save', {
id: eventId.value,
trigger: trigger.value,
condition: condition.value || undefined,
action: {
type: actionType.value,
payload: Object.keys(actionPayload.value).length ? actionPayload.value : undefined,
},
})
dialogVisible.value = false
}
function onCancel() {
dialogVisible.value = false
}
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="480px"
:append-to-body="true"
destroy-on-close
@close="onCancel"
>
<div class="dialog-form">
<div class="form-row">
<span class="form-label">触发方式</span>
<el-select v-model="trigger" class="form-control">
<el-option
v-for="opt in triggerOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="form-row">
<span class="form-label">条件</span>
<el-input
v-model="condition"
class="form-control"
placeholder="可选,如: vars['Pump.Status'] === 1"
/>
</div>
<div class="form-row">
<span class="form-label">动作类型</span>
<el-select v-model="actionType" class="form-control">
<el-option
v-for="opt in actionTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<!-- 调用方法 -->
<div v-if="actionType === 'callMethod'" class="form-row">
<span class="form-label">方法名</span>
<el-input
:model-value="(actionPayload.method as string) ?? ''"
class="form-control"
placeholder="预留"
@update:model-value="(v: string) => actionPayload = { method: v }"
/>
</div>
<!-- 切换画布 -->
<div v-if="actionType === 'navigateCanvas'" class="form-row">
<span class="form-label">目标画布</span>
<el-select
:model-value="(actionPayload.canvasId as string) ?? ''"
filterable
class="form-control"
placeholder="选择画布"
@update:model-value="(v: string) => actionPayload = { canvasId: v }"
>
<el-option
v-for="opt in canvasOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
</div>
<!-- 预览 -->
<div class="dialog-preview">
<span class="preview-label">预览</span>
<div class="preview-box">
<span class="preview-text">{{ previewText }}</span>
</div>
</div>
<template #footer>
<el-button @click="onCancel">
取消
</el-button>
<el-button type="primary" :disabled="saveDisabled" @click="onSave">
确定
</el-button>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
// 与 BindingConfigDialog 保持一致的表单样式
.dialog-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-row {
display: flex;
align-items: center;
gap: 8px;
}
.form-label {
min-width: 70px;
flex-shrink: 0;
font-size: 14px;
color: var(--ed-text-secondary, #6b6b6b);
text-align: right;
}
.form-control {
flex: 1;
min-width: 0;
}
.dialog-preview {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--ed-border, #f0f0f0);
}
.preview-label {
display: block;
font-size: 12px;
color: var(--ed-text-tertiary, #8c8c8c);
margin-bottom: 8px;
}
.preview-box {
padding: 10px 12px;
background: var(--ed-bg-raised, #fafafa);
border-radius: 6px;
border: 1px solid var(--ed-border, #f0f0f0);
display: flex;
align-items: center;
gap: 8px;
}
.preview-text {
flex: 1;
font-size: 14px;
color: var(--ed-text-primary, #595959);
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
import type { ComponentEvent } from '../../../types'
import { computed, ref } from 'vue'
import { EdIconButton, EdSection } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { setEditorEmitter } from '../../../emitter'
import EventConfigDialog from './EventConfigDialog.vue'
const { selectedLayer, selectedGroup, canvasList } = useCanvasState()
const events = computed<ComponentEvent[]>(() => selectedLayer.value?.events ?? [])
const canEdit = computed(() => Boolean(selectedLayer.value && !selectedGroup.value))
// 对话框状态
const dialogVisible = ref(false)
const dialogIsEditing = ref(false)
const dialogEditingEvent = ref<ComponentEvent | null>(null)
const canvasOptions = computed(() => (canvasList.value ?? []).map(c => ({ label: c.name, value: c.id })))
function updateEvents(next: ComponentEvent[]) {
if (!selectedLayer.value)
return
setEditorEmitter('layer:updateField', {
id: selectedLayer.value.id,
field: 'events',
value: next.length ? next : undefined,
})
}
// 标签映射
const triggerLabels: Record<string, string> = { click: '单击', dblclick: '双击' }
const actionLabels: Record<string, string> = { callMethod: '调用方法', navigateCanvas: '切换画布' }
function getCanvasName(id: string) {
return canvasList.value?.find(c => c.id === id)?.name ?? id
}
function getEventIcon(event: ComponentEvent) {
return event.trigger === 'dblclick' ? 'fa-solid fa-computer-mouse' : 'fa-solid fa-hand-pointer'
}
function getEventLabel(event: ComponentEvent) {
return actionLabels[event.action.type] ?? event.action.type
}
function getEventDetail(event: ComponentEvent) {
const parts: string[] = [triggerLabels[event.trigger] ?? event.trigger]
if (event.action.type === 'navigateCanvas' && event.action.payload?.canvasId)
parts.push(`${getCanvasName(event.action.payload.canvasId as string)}`)
else if (event.action.type === 'callMethod' && event.action.payload?.method)
parts.push(`${event.action.payload.method}`)
if (event.condition)
parts.push(`| ${event.condition}`)
return parts.join(' ')
}
// 添加事件
function openAddDialog() {
dialogIsEditing.value = false
dialogEditingEvent.value = null
dialogVisible.value = true
}
// 编辑事件
function openEditDialog(event: ComponentEvent) {
dialogIsEditing.value = true
dialogEditingEvent.value = {
...event,
action: {
...event.action,
payload: event.action.payload ? { ...event.action.payload } : undefined,
},
}
dialogVisible.value = true
}
// 保存事件(来自弹窗)
function onSaveEvent(data: ComponentEvent) {
if (dialogIsEditing.value) {
updateEvents(events.value.map(e => e.id === data.id ? data : e))
}
else {
updateEvents([...events.value, data])
}
}
// 删除事件
function deleteEvent(event: ComponentEvent) {
updateEvents(events.value.filter(e => e.id !== event.id))
}
</script>
<template>
<EdSection v-if="canEdit" title="事件">
<template #actions>
<EdIconButton icon="fa-solid fa-plus" title="添加事件" @click="openAddDialog" />
</template>
<!-- 事件卡片列表 -->
<div v-if="events.length" class="event-list">
<div
v-for="event in events"
:key="event.id"
class="event-item"
>
<i class="event-item__icon" :class="getEventIcon(event)" />
<div class="event-item__info">
<span class="event-item__label">{{ getEventLabel(event) }}</span>
<span class="event-item__detail">{{ getEventDetail(event) }}</span>
</div>
<div class="event-item__actions">
<button
type="button"
class="event-item__btn"
title="编辑"
@click="openEditDialog(event)"
>
<i class="fa-solid fa-pen-to-square" />
</button>
<button
type="button"
class="event-item__btn event-item__btn--danger"
title="删除"
@click="deleteEvent(event)"
>
<i class="fa-solid fa-trash-can" />
</button>
</div>
</div>
</div>
<div v-else class="event-empty">
暂无事件点击 + 添加
</div>
<!-- 单事件配置对话框 -->
<EventConfigDialog
v-model:visible="dialogVisible"
:is-editing="dialogIsEditing"
:editing-event="dialogEditingEvent"
:canvas-options="canvasOptions"
@save="onSaveEvent"
/>
</EdSection>
</template>
<style lang="scss" scoped>
// 与 SectionDataBinding 的 binding-list / binding-item 保持一致
.event-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.event-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--ed-border);
background: var(--ed-bg-surface);
transition: border-color 0.15s;
&:hover {
border-color: var(--ed-accent);
}
}
.event-item__icon {
width: 18px;
text-align: center;
font-size: 11px;
color: var(--ed-accent);
flex-shrink: 0;
}
.event-item__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.event-item__label {
font-size: 12px;
color: var(--ed-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.event-item__detail {
font-size: 11px;
color: var(--ed-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-item__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.event-item__btn {
width: 22px;
height: 22px;
border: none;
background: transparent;
color: var(--ed-text-tertiary);
cursor: pointer;
border-radius: 4px;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
&:hover {
background: var(--ed-bg-hover);
color: var(--ed-text-primary);
}
&--danger:hover {
color: #ff4d4f;
}
}
.event-empty {
font-size: 12px;
color: var(--ed-text-disabled);
text-align: center;
padding: 12px 0;
}
</style>

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
/**
* 渐变编辑器 — 线性/径向渐变色标编辑
*/
import { computed } from 'vue'
import { EdColorInput, EdNumberInput, EdSegmented } from '../../../../controls'
const props = defineProps<{
type: 'linear' | 'radial'
colors: string[]
stops: number[]
angle: number
}>()
const emit = defineEmits<{
(e: 'update:type', val: 'linear' | 'radial'): void
(e: 'update:colors', val: string[]): void
(e: 'update:stops', val: number[]): void
(e: 'update:angle', val: number): void
}>()
const gradientTypeOptions = [
{ label: '线性', value: 'linear' },
{ label: '径向', value: 'radial' },
]
// 渐变预览条
const previewGradient = computed(() => {
const stops = props.colors.map((c, i) => {
const pos = Math.round((props.stops[i] ?? (i / Math.max(props.colors.length - 1, 1))) * 100)
return `${c} ${pos}%`
}).join(', ')
if (props.type === 'radial') {
return `radial-gradient(circle, ${stops})`
}
return `linear-gradient(${props.angle}deg, ${stops})`
})
// 色标列表
const colorStops = computed(() =>
props.colors.map((color, i) => ({
color,
position: Math.round((props.stops[i] ?? (i / Math.max(props.colors.length - 1, 1))) * 100),
})),
)
function updateColor(index: number, color: string) {
const newColors = [...props.colors]
newColors[index] = color
emit('update:colors', newColors)
}
function updatePosition(index: number, position: number) {
const newStops = [...props.stops]
newStops[index] = Math.max(0, Math.min(1, position / 100))
emit('update:stops', newStops)
}
function addStop() {
emit('update:colors', [...props.colors, '#999999'])
emit('update:stops', [...props.stops, 0.5])
}
function removeStop(index: number) {
if (props.colors.length <= 2)
return
const newColors = props.colors.filter((_, i) => i !== index)
const newStops = props.stops.filter((_, i) => i !== index)
emit('update:colors', newColors)
emit('update:stops', newStops)
}
</script>
<template>
<div class="gradient-editor">
<!-- 渐变预览条 -->
<div class="gradient-preview" :style="{ background: previewGradient }" />
<!-- 类型 + 角度 -->
<div class="gradient-controls">
<EdSegmented :model-value="type" :options="gradientTypeOptions" @update:model-value="$emit('update:type', $event as 'linear' | 'radial')" />
<EdNumberInput
v-if="type === 'linear'"
:model-value="angle"
:min="0"
:max="360"
:step="15"
label="°"
@update:model-value="$emit('update:angle', $event)"
/>
</div>
<!-- 色标列表 -->
<div v-for="(stop, index) in colorStops" :key="index" class="stop-row">
<EdColorInput
:model-value="stop.color"
@update:model-value="updateColor(index, $event)"
/>
<EdNumberInput
:model-value="stop.position"
:min="0"
:max="100"
:step="5"
label="%"
@update:model-value="updatePosition(index, $event)"
/>
<button
v-if="colors.length > 2"
type="button"
class="stop-remove-btn"
title="移除色标"
@click="removeStop(index)"
>
<i class="fa-solid fa-xmark" />
</button>
</div>
<button type="button" class="stop-add-btn" @click="addStop">
<i class="fa-solid fa-plus" />
添加色标
</button>
</div>
</template>
<style scoped>
.gradient-editor {
display: flex;
flex-direction: column;
gap: 6px;
}
.gradient-preview {
height: 20px;
border-radius: var(--ed-radius-sm, 4px);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.gradient-controls {
display: grid;
grid-template-columns: 1fr 64px;
gap: var(--ed-space-4, 4px);
align-items: center;
}
.stop-row {
display: grid;
grid-template-columns: 1fr 64px auto;
gap: var(--ed-space-4, 4px);
align-items: center;
}
.stop-remove-btn {
appearance: none;
border: none;
background: transparent;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--ed-radius-sm, 4px);
color: var(--ed-text-tertiary, #8c8c8c);
font-size: 10px;
cursor: pointer;
transition: all var(--ed-transition-fast, 0.15s);
}
.stop-remove-btn:hover {
background: var(--ed-bg-hover, #f5f5f5);
color: var(--ed-danger, #ff4d4f);
}
.stop-add-btn {
appearance: none;
border: none;
background: transparent;
height: var(--ed-control-height, 24px);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-family: var(--ed-font);
font-size: var(--ed-font-sm, 11px);
color: var(--ed-text-tertiary, #8c8c8c);
cursor: pointer;
border-radius: var(--ed-radius-sm, 4px);
transition: all var(--ed-transition-fast, 0.15s);
}
.stop-add-btn:hover {
background: var(--ed-bg-hover, #f5f5f5);
color: var(--ed-accent, #1677ff);
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdColorInput, EdInput, EdNumberInput, EdSection } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer } = useCanvasState()
const { updateField } = usePropertyContext()
const headerColor = computed(() => String(selectedLayer.value?.config?.headerColor ?? '#0055ff'))
const labelColor = computed(() => String(selectedLayer.value?.config?.labelColor ?? '#00cc00'))
const valueColor = computed(() => String(selectedLayer.value?.config?.valueColor ?? '#ffffff'))
const bgColor = computed(() => String(selectedLayer.value?.config?.bgColor ?? '#1a1a2e'))
const decimalPlaces = computed(() => Number(selectedLayer.value?.config?.decimalPlaces ?? 2))
const opDecimalPlaces = computed(() => Number(selectedLayer.value?.config?.opDecimalPlaces ?? 2))
const description = computed(() => String(selectedLayer.value?.config?.description ?? ''))
</script>
<template>
<EdSection title="控制仪表">
<div class="ctrl-config-grid">
<div class="ctrl-config-item">
<span class="ctrl-config-label">描述</span>
<EdInput
:model-value="description"
placeholder="设备描述"
@update:model-value="updateField('config.description', $event)"
/>
</div>
<div class="ctrl-config-item">
<span class="ctrl-config-label">小数位数</span>
<EdNumberInput
:model-value="decimalPlaces"
:min="0"
:max="6"
:step="1"
@update:model-value="updateField('config.decimalPlaces', $event)"
/>
</div>
<div class="ctrl-config-item">
<span class="ctrl-config-label">OP小数</span>
<EdNumberInput
:model-value="opDecimalPlaces"
:min="0"
:max="6"
:step="1"
@update:model-value="updateField('config.opDecimalPlaces', $event)"
/>
</div>
<div class="ctrl-config-item">
<span class="ctrl-config-label">标题栏</span>
<EdColorInput :model-value="headerColor" @update:model-value="updateField('config.headerColor', $event)" />
</div>
<div class="ctrl-config-item">
<span class="ctrl-config-label">背景色</span>
<EdColorInput :model-value="bgColor" @update:model-value="updateField('config.bgColor', $event)" />
</div>
<div class="ctrl-config-item">
<span class="ctrl-config-label">标签色</span>
<EdColorInput :model-value="labelColor" @update:model-value="updateField('config.labelColor', $event)" />
</div>
<div class="ctrl-config-item">
<span class="ctrl-config-label">数值色</span>
<EdColorInput :model-value="valueColor" @update:model-value="updateField('config.valueColor', $event)" />
</div>
</div>
</EdSection>
</template>
<style scoped>
.ctrl-config-grid {
display: flex;
flex-direction: column;
gap: var(--ed-space-4);
}
.ctrl-config-item {
display: flex;
align-items: center;
gap: 8px;
}
.ctrl-config-label {
font-size: 12px;
color: var(--ed-text-secondary, #606266);
min-width: 48px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,600 @@
<script setup lang="ts">
import type { LayerBindingDefinition, LayerBindingValue } from '../../../types'
import { computed, ref } from 'vue'
import { generateUUID } from '@/utils'
import { EdIconButton, EdSection } from '../../../../controls'
import { useCanvasRuntimeInject } from '../../../context'
import { useCanvasState } from '../../../context/state'
import { setEditorEmitter } from '../../../emitter'
import BindingConfigDialog from './BindingConfigDialog.vue'
interface BindingFieldOption {
label: string
value: string
}
interface SummaryItem {
id: string
kind: 'binding' | 'conditional'
icon: string
label: string
detail: string
enabled: boolean
field?: string
binding?: LayerBindingDefinition
conditional?: { id: string, condition: string, style: Record<string, any>, priority?: number, enabled?: boolean }
}
const VARIABLE_EXPRESSION_RE = /vars\[['"]([^'"]+)['"]\]/g
const { selectedLayer, selectedGroup } = useCanvasState()
const { isLoadingVariables, variableLoadError, variableOptions, ensureVariableCatalogLoaded } = useCanvasRuntimeInject()
const canEdit = computed(() => Boolean(selectedLayer.value && !selectedGroup.value))
// 对话框状态
const dialogVisible = ref(false)
const dialogIsEditing = ref(false)
const dialogMode = ref<'binding' | 'conditional'>('binding')
const dialogEditingBinding = ref<{
id: string
type: 'variable' | 'expression'
value: string
variables?: string[]
priority: number
enabled: boolean
field: string
} | null>(null)
const dialogEditingConditional = ref<{
id: string
condition: string
style: Record<string, any>
priority: number
enabled: boolean
} | null>(null)
// 绑定字段选项
const bindingFieldOptions = computed<BindingFieldOption[]>(() => {
if (!selectedLayer.value)
return []
const commonStyleFields: BindingFieldOption[] = [
{ label: '边框颜色', value: 'style.border.color' },
{ label: '边框宽度', value: 'style.border.width' },
{ label: '圆角', value: 'style.border.radius' },
{ label: '阴影颜色', value: 'style.shadow.color' },
{ label: '阴影模糊', value: 'style.shadow.blur' },
{ label: '阴影 X 偏移', value: 'style.shadow.offsetX' },
{ label: '阴影 Y 偏移', value: 'style.shadow.offsetY' },
]
const layerType = selectedLayer.value.type
if (layerType === 'rect') {
return [
{ label: '背景色', value: 'style.fill.color' },
{ label: '背景透明度', value: 'style.fill.opacity' },
...commonStyleFields,
]
}
if (layerType === 'bar') {
return [
{ label: '当前值', value: 'config.value' },
{ label: '数值内容', value: 'config.valueContent' },
{ label: '最小值', value: 'config.min' },
{ label: '最大值', value: 'config.max' },
{ label: '背景色', value: 'style.fill.color' },
{ label: '背景透明度', value: 'style.fill.opacity' },
...commonStyleFields,
]
}
if (layerType === 'button') {
return [
{ label: '按钮文本', value: 'config.label' },
{ label: '文字颜色', value: 'style.text.color' },
{ label: '背景色', value: 'style.fill.color' },
{ label: '背景透明度', value: 'style.fill.opacity' },
...commonStyleFields,
]
}
return [
{ label: layerType === 'number' ? '数值' : '文本内容', value: 'value' },
{ label: '文字颜色', value: 'style.text.color' },
{ label: '背景色', value: 'style.fill.color' },
{ label: '背景透明度', value: 'style.fill.opacity' },
{ label: '字号', value: 'style.text.fontSize' },
{ label: '字重', value: 'style.text.fontWeight' },
...commonStyleFields,
]
})
// 如果图层已绑定位号,只展示该位号(模块)下的属性
const cascaderOptions = computed(() => {
const tagNumber = selectedLayer.value?.tagNumber
if (!tagNumber)
return variableOptions.value
return variableOptions.value.filter(opt => opt.value === tagNumber)
})
function getFieldLabel(field: string) {
return bindingFieldOptions.value.find(o => o.value === field)?.label ?? field
}
// 归一化绑定数据stableId 用于 computed 中避免每次生成新 UUID
function normalizeBinding(binding: string | LayerBindingDefinition | undefined, stableId?: string): LayerBindingDefinition | null {
if (!binding)
return null
if (typeof binding === 'string') {
return {
id: stableId || generateUUID(),
type: 'variable',
value: binding,
variables: binding ? [binding] : undefined,
priority: 0,
enabled: true,
}
}
if ((binding.type === 'variable' || binding.type === 'expression') && typeof binding.value === 'string') {
return {
...binding,
id: binding.id || stableId || generateUUID(),
priority: typeof binding.priority === 'number' && Number.isFinite(binding.priority) ? binding.priority : 0,
enabled: binding.enabled !== false,
}
}
return null
}
function normalizeBindingList(binding: LayerBindingValue | undefined, fieldKey?: string): LayerBindingDefinition[] {
if (Array.isArray(binding)) {
return binding.map((item, i) => normalizeBinding(item, fieldKey ? `${fieldKey}:${i}` : undefined)).filter(Boolean) as LayerBindingDefinition[]
}
const normalized = normalizeBinding(binding)
return normalized ? [normalized] : []
}
function inferExpressionVariables(expression: string) {
const matches = expression.matchAll(VARIABLE_EXPRESSION_RE)
return [...new Set(Array.from(matches, match => match[1]).filter(Boolean))]
}
// 摘要列表
const summaryItems = computed<SummaryItem[]>(() => {
const items: SummaryItem[] = []
const layer = selectedLayer.value
if (!layer)
return items
// 属性绑定
if (layer.bindings) {
for (const [field, binding] of Object.entries(layer.bindings)) {
for (const def of normalizeBindingList(binding, field)) {
items.push({
id: def.id || field,
kind: 'binding',
icon: def.type === 'variable' ? 'fa-solid fa-bolt' : 'fa-solid fa-code',
label: getFieldLabel(field),
detail: def.type === 'variable'
? def.value
: (def.value.length > 30 ? `${def.value.slice(0, 30)}...` : def.value),
enabled: def.enabled !== false,
field,
binding: def,
})
}
}
}
// 条件样式
if (layer.conditionalStyles) {
for (const rule of layer.conditionalStyles) {
const effects: string[] = []
const bgColor = rule.style?.backgroundColor || rule.style?.fillColor
if (bgColor)
effects.push(`填充 ${bgColor}`)
if (rule.style?.textColor)
effects.push(`文字 ${rule.style.textColor}`)
if (rule.style?.opacity != null)
effects.push(`透明度 ${rule.style.opacity}`)
if (rule.style?.borderColor)
effects.push(`边框 ${rule.style.borderColor}`)
if (rule.style?.visible === false)
effects.push('隐藏')
if (rule.style?.blink === true)
effects.push('闪烁')
items.push({
id: rule.id,
kind: 'conditional',
icon: 'fa-solid fa-code-branch',
label: rule.condition || '(空条件)',
detail: effects.join('') || '无样式变更',
enabled: rule.enabled !== false,
conditional: rule,
})
}
}
return items
})
// 打开对话框
function openAddDialog() {
void ensureVariableCatalogLoaded(!variableOptions.value.length)
dialogIsEditing.value = false
dialogMode.value = 'binding'
dialogEditingBinding.value = null
dialogEditingConditional.value = null
dialogVisible.value = true
}
function openEditDialog(item: SummaryItem) {
void ensureVariableCatalogLoaded(!variableOptions.value.length)
dialogIsEditing.value = true
if (item.kind === 'binding' && item.binding && item.field) {
dialogMode.value = 'binding'
dialogEditingBinding.value = {
...item.binding,
id: item.binding.id || item.id,
field: item.field,
priority: item.binding.priority ?? 0,
enabled: item.binding.enabled !== false,
}
dialogEditingConditional.value = null
}
else if (item.kind === 'conditional' && item.conditional) {
dialogMode.value = 'conditional'
dialogEditingConditional.value = {
id: item.conditional.id,
condition: item.conditional.condition,
style: { ...item.conditional.style },
priority: item.conditional.priority ?? 0,
enabled: item.conditional.enabled !== false,
}
dialogEditingBinding.value = null
}
dialogVisible.value = true
}
// 保存绑定
function onSaveBinding(data: {
id: string
field: string
type: 'variable' | 'expression'
value: string
priority: number
enabled: boolean
}) {
const layer = selectedLayer.value
if (!layer)
return
const bindings: Record<string, LayerBindingDefinition[]> = {}
// 归一化现有绑定
if (layer.bindings) {
for (const [field, binding] of Object.entries(layer.bindings)) {
const list = normalizeBindingList(binding)
if (list.length)
bindings[field] = list
}
}
// 从所有字段中移除该 ID处理字段变更
for (const [field, list] of Object.entries(bindings)) {
bindings[field] = list.filter(b => b.id !== data.id)
if (!bindings[field].length)
delete bindings[field]
}
// 添加到目标字段
if (!bindings[data.field])
bindings[data.field] = []
bindings[data.field].push({
id: data.id,
type: data.type,
value: data.value,
priority: data.priority,
enabled: data.enabled,
variables: data.type === 'expression'
? inferExpressionVariables(data.value)
: data.value
? [data.value]
: undefined,
})
setEditorEmitter('layer:updateField', {
id: layer.id,
field: 'bindings',
value: Object.keys(bindings).length ? bindings : undefined,
})
}
// 保存条件样式
function onSaveConditional(data: {
id: string
condition: string
style: Record<string, any>
priority: number
enabled: boolean
}) {
const layer = selectedLayer.value
if (!layer)
return
const rules = [...(layer.conditionalStyles ?? [])]
const existingIndex = rules.findIndex(r => r.id === data.id)
const newRule = {
id: data.id,
condition: data.condition,
style: data.style,
priority: data.priority,
enabled: data.enabled,
}
if (existingIndex >= 0) {
rules[existingIndex] = newRule
}
else {
rules.push(newRule)
}
setEditorEmitter('layer:updateField', {
id: layer.id,
field: 'conditionalStyles',
value: rules.length ? rules : undefined,
})
}
// 切换启用/禁用
function toggleItem(item: SummaryItem) {
const nextEnabled = !item.enabled
if (item.kind === 'binding' && item.binding && item.field) {
const layer = selectedLayer.value
if (!layer)
return
const bindings: Record<string, LayerBindingDefinition[]> = {}
if (layer.bindings) {
for (const [field, binding] of Object.entries(layer.bindings)) {
const list = normalizeBindingList(binding).map((b) => {
if (b.id === item.id) {
return { ...b, enabled: nextEnabled }
}
return b
})
if (list.length)
bindings[field] = list
}
}
setEditorEmitter('layer:updateField', {
id: layer.id,
field: 'bindings',
value: Object.keys(bindings).length ? bindings : undefined,
})
}
else if (item.kind === 'conditional') {
const layer = selectedLayer.value
if (!layer)
return
const rules = (layer.conditionalStyles ?? []).map((r) => {
if (r.id === item.id) {
return { ...r, enabled: nextEnabled }
}
return r
})
setEditorEmitter('layer:updateField', {
id: layer.id,
field: 'conditionalStyles',
value: rules.length ? rules : undefined,
})
}
}
// 删除
function deleteItem(item: SummaryItem) {
const layer = selectedLayer.value
if (!layer)
return
if (item.kind === 'binding') {
const bindings: Record<string, LayerBindingDefinition[]> = {}
if (layer.bindings) {
for (const [field, binding] of Object.entries(layer.bindings)) {
const filtered = normalizeBindingList(binding).filter(b => b.id !== item.id)
if (filtered.length)
bindings[field] = filtered
}
}
setEditorEmitter('layer:updateField', {
id: layer.id,
field: 'bindings',
value: Object.keys(bindings).length ? bindings : undefined,
})
}
else if (item.kind === 'conditional') {
const rules = (layer.conditionalStyles ?? []).filter(r => r.id !== item.id)
setEditorEmitter('layer:updateField', {
id: layer.id,
field: 'conditionalStyles',
value: rules.length ? rules : undefined,
})
}
}
</script>
<template>
<EdSection v-if="canEdit" title="数据绑定">
<template #actions>
<EdIconButton icon="fa-solid fa-plus" title="添加绑定" @click="openAddDialog" />
</template>
<!-- 绑定摘要列表 -->
<div v-if="summaryItems.length" class="binding-list">
<div
v-for="item in summaryItems"
:key="item.id"
class="binding-item"
:class="{ 'binding-item--disabled': !item.enabled }"
>
<i class="binding-item__icon" :class="item.icon" />
<div class="binding-item__info">
<span class="binding-item__label">{{ item.label }}</span>
<span class="binding-item__detail">{{ item.detail }}</span>
</div>
<div class="binding-item__actions">
<el-switch
:model-value="item.enabled"
size="small"
@update:model-value="toggleItem(item)"
/>
<button
type="button"
class="binding-item__btn"
title="编辑"
@click="openEditDialog(item)"
>
<i class="fa-solid fa-pen-to-square" />
</button>
<button
type="button"
class="binding-item__btn binding-item__btn--danger"
title="删除"
@click="deleteItem(item)"
>
<i class="fa-solid fa-trash-can" />
</button>
</div>
</div>
</div>
<div v-else class="binding-empty">
暂无绑定点击 + 添加变量绑定或条件样式
</div>
<!-- 配置对话框 -->
<BindingConfigDialog
v-model:visible="dialogVisible"
:is-editing="dialogIsEditing"
:mode="dialogMode"
:field-options="bindingFieldOptions"
:cascader-options="cascaderOptions"
:is-loading-variables="isLoadingVariables"
:variable-load-error="variableLoadError"
:editing-binding="dialogEditingBinding"
:editing-conditional="dialogEditingConditional"
@save-binding="onSaveBinding"
@save-conditional="onSaveConditional"
@retry-load="ensureVariableCatalogLoaded(true)"
/>
</EdSection>
</template>
<style lang="scss" scoped>
.binding-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.binding-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--ed-border);
background: var(--ed-bg-surface);
transition: border-color 0.15s, opacity 0.15s;
&:hover {
border-color: var(--ed-accent);
}
&--disabled {
opacity: 0.5;
}
}
.binding-item__icon {
width: 18px;
text-align: center;
font-size: 11px;
color: var(--ed-accent);
flex-shrink: 0;
}
.binding-item__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.binding-item__label {
font-size: 12px;
color: var(--ed-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.binding-item__detail {
font-size: 11px;
color: var(--ed-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.binding-item__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.binding-item__btn {
width: 22px;
height: 22px;
border: none;
background: transparent;
color: var(--ed-text-tertiary);
cursor: pointer;
border-radius: 4px;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
&:hover {
background: var(--ed-bg-hover);
color: var(--ed-text-primary);
}
&--danger:hover {
color: #ff4d4f;
}
}
.binding-empty {
font-size: 12px;
color: var(--ed-text-disabled);
text-align: center;
padding: 12px 0;
}
</style>

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdColorInput, EdNumberInput, EdSection, EdSegmented, EdSelect } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
import GradientEditor from './GradientEditor.vue'
const { selectedLayer } = useCanvasState()
const { updateField } = usePropertyContext()
const DEFAULT_RECT_FILL = '#B7B7B7'
const fillColorFallback = computed(() => {
if (selectedLayer.value?.type === 'rect') {
return DEFAULT_RECT_FILL
}
return ''
})
// 棒图只支持纯色填充,不提供渐变选项。
const solidOnly = computed(() => selectedLayer.value?.type === 'bar')
const fillColor = computed(() => {
return String(selectedLayer.value?.config?.fillColor ?? fillColorFallback.value)
})
const fillOpacity = computed(() => {
const raw = selectedLayer.value?.config?.fillOpacity
if (typeof raw === 'number') {
return raw
}
return 100
})
// 填充类型solid | gradient | image
// 优先使用显式标记,兼容无标记的旧数据
const fillType = computed(() => {
const active = selectedLayer.value?.style?.fill?.activeType
if (active)
return active === 'linear' || active === 'radial' ? 'gradient' : active
const image = selectedLayer.value?.style?.fill?.image
if (image && 'src' in image)
return 'image'
const gradient = selectedLayer.value?.style?.fill?.gradient
if (gradient?.type)
return 'gradient'
return 'solid'
})
const fillTypeOptions = [
{ label: '纯色', value: 'solid' },
{ label: '渐变', value: 'gradient' },
{ label: '图片', value: 'image' },
]
function onFillTypeChange(type: string) {
updateField('style.fill.activeType', type)
if (type === 'image') {
// 没有已有图片数据时才初始化
if (!selectedLayer.value?.style?.fill?.image) {
updateField('style.fill.image', { src: '', fit: 'cover', opacity: 1 })
}
}
else if (type === 'gradient') {
// 没有已有渐变数据时才初始化
if (!selectedLayer.value?.style?.fill?.gradient?.type) {
updateField('style.fill.gradient', {
type: 'linear',
colors: ['#ffffff', '#000000'],
stops: [0, 1],
angle: 180,
})
}
}
}
function updateGradientField(subField: string, value: unknown) {
updateField(`style.fill.gradient.${subField}`, value)
}
// 图片填充
const fillImage = computed(() => selectedLayer.value?.style?.fill?.image)
const fillImageFit = computed(() => fillImage.value?.fit ?? 'cover')
const fillImageOpacity = computed(() => Math.round((fillImage.value?.opacity ?? 1) * 100))
const imageFitOptions = [
{ label: '覆盖', value: 'cover' },
{ label: '包含', value: 'contain' },
{ label: '拉伸', value: 'fill' },
]
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
function onImageUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file)
return
if (file.size > MAX_IMAGE_SIZE) {
import('element-plus').then(({ ElMessage }) => {
ElMessage.warning('图片大小不能超过 2MB')
})
return
}
const reader = new FileReader()
reader.onload = (e) => {
updateField('style.fill.image.src', e.target?.result as string)
}
reader.readAsDataURL(file)
}
</script>
<template>
<EdSection title="填充">
<div v-if="!solidOnly" class="fill-type-switch">
<EdSegmented :model-value="fillType" :options="fillTypeOptions" @update:model-value="onFillTypeChange" />
</div>
<!-- 纯色模式 -->
<template v-if="solidOnly || fillType === 'solid'">
<div class="fill-row">
<EdColorInput
:model-value="fillColor"
placeholder="无"
@update:model-value="updateField('config.fillColor', $event)"
/>
<EdNumberInput
:model-value="fillOpacity"
:min="0"
:max="100"
:step="1"
label="%"
@update:model-value="updateField('config.fillOpacity', $event)"
/>
</div>
</template>
<!-- 渐变模式 -->
<template v-else-if="fillType === 'gradient'">
<GradientEditor
:type="selectedLayer?.style?.fill?.gradient?.type ?? 'linear'"
:colors="selectedLayer?.style?.fill?.gradient?.colors ?? ['#ffffff', '#000000']"
:stops="selectedLayer?.style?.fill?.gradient?.stops ?? [0, 1]"
:angle="selectedLayer?.style?.fill?.gradient?.angle ?? 180"
@update:type="v => updateGradientField('type', v)"
@update:colors="v => updateGradientField('colors', v)"
@update:stops="v => updateGradientField('stops', v)"
@update:angle="v => updateGradientField('angle', v)"
/>
</template>
<!-- 图片填充模式 -->
<template v-else-if="fillType === 'image'">
<div class="fill-image-area">
<div class="fill-image-preview">
<img v-if="fillImage?.src" :src="fillImage.src" class="fill-image-thumb">
<label v-else class="fill-image-upload">
<i class="fa-solid fa-image" />
<span>选择图片</span>
<input type="file" accept="image/*" hidden @change="onImageUpload">
</label>
<label v-if="fillImage?.src" class="fill-image-change">
<span>更换</span>
<input type="file" accept="image/*" hidden @change="onImageUpload">
</label>
</div>
<div class="fill-image-options">
<EdSelect
:model-value="fillImageFit"
:options="imageFitOptions"
@update:model-value="updateField('style.fill.image.fit', $event)"
/>
<EdNumberInput
:model-value="fillImageOpacity"
:min="0"
:max="100"
:step="1"
label="%"
@update:model-value="updateField('style.fill.image.opacity', $event / 100)"
/>
</div>
</div>
</template>
</EdSection>
</template>
<style scoped>
.fill-type-switch {
margin-bottom: 8px;
}
.fill-row {
display: grid;
grid-template-columns: 1fr 64px;
gap: var(--ed-space-4);
}
.fill-image-area {
display: flex;
flex-direction: column;
gap: var(--ed-space-4);
}
.fill-image-preview {
position: relative;
width: 100%;
height: 64px;
border: 1px dashed #d9d9d9;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.fill-image-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.fill-image-upload {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
color: #8c8c8c;
font-size: 12px;
}
.fill-image-change {
position: absolute;
bottom: 2px;
right: 4px;
cursor: pointer;
font-size: 11px;
color: var(--ed-accent, #1677ff);
}
.fill-image-options {
display: grid;
grid-template-columns: 1fr 64px;
gap: var(--ed-space-4);
}
</style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdNumberInput, EdSection } from '../../../../controls'
import { useCanvasRuntimeInject } from '../../../context'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer, selectedGroup } = useCanvasState()
const { variableMap, isLoadingVariables, variableLoadError, ensureVariableCatalogLoaded } = useCanvasRuntimeInject()
const { updateField } = usePropertyContext()
// 业务组件才显示位号选择基础组件rect/number/text/button不需要
const BUSINESS_TYPES = new Set(['bar', 'pidController', 'valveController', 'canvasSwitcher'])
const showTagNumber = computed(() =>
!selectedGroup.value && !!selectedLayer.value && BUSINESS_TYPES.has(selectedLayer.value.type),
)
// 属性面板挂载时确保变量目录已加载,否则位号下拉为空。
ensureVariableCatalogLoaded()
function isAllowedDevice(moduleLabel: string, componentType: string | undefined): boolean {
if (componentType === 'bar') {
return moduleLabel === 'Vessel'
|| moduleLabel.startsWith('Tank_')
|| moduleLabel.startsWith('Dtower')
|| moduleLabel.startsWith('RCSTR')
|| moduleLabel.startsWith('FlashTank')
}
if (componentType === 'pidController') {
return moduleLabel === 'PID_M'
|| moduleLabel === 'PID'
|| moduleLabel === 'SelfTuning_PID'
}
if (componentType === 'valveController') {
return moduleLabel.includes('Valve')
}
return true
}
// 从变量目录提取位号候选(只取模块名,不含属性)
const tagNumberOptions = computed<string[]>(() => {
if (!variableMap.value)
return []
const componentType = selectedLayer.value?.type
return [...new Set(
Object.values(variableMap.value)
.filter(v => isAllowedDevice(v.moduleLabel, componentType))
.map(v => v.moduleName),
)]
})
function onTagNumberChange(val: string) {
updateField('tagNumber', val || undefined)
}
</script>
<template>
<EdSection title="位置与尺寸">
<div class="geometry-grid">
<EdNumberInput
label="X"
:model-value="selectedGroup?.x ?? selectedLayer?.x ?? 0"
:step="1"
@update:model-value="updateField('x', $event)"
/>
<EdNumberInput
label="Y"
:model-value="selectedGroup?.y ?? selectedLayer?.y ?? 0"
:step="1"
@update:model-value="updateField('y', $event)"
/>
<EdNumberInput
label="W"
:model-value="selectedGroup?.width ?? selectedLayer?.width ?? 0"
:min="1"
:step="1"
@update:model-value="updateField('width', $event)"
/>
<EdNumberInput
label="H"
:model-value="selectedGroup?.height ?? selectedLayer?.height ?? 0"
:min="1"
:step="1"
@update:model-value="updateField('height', $event)"
/>
<EdNumberInput
label="°"
:model-value="selectedLayer?.config?.rotate ?? 0"
:min="0"
:max="360"
:step="1"
class="geo-full"
@update:model-value="updateField('config.rotate', $event)"
/>
</div>
<!-- 设备位号仅业务组件显示 -->
<div v-if="showTagNumber" class="tag-number-row">
<span class="tag-label">设备位号</span>
<el-select
:model-value="selectedLayer?.tagNumber || ''"
:loading="isLoadingVariables"
filterable
allow-create
clearable
size="small"
:placeholder="variableLoadError ? '未关联项目' : '如 FT-101'"
class="tag-select"
@update:model-value="onTagNumberChange"
>
<el-option
v-for="tag in tagNumberOptions"
:key="tag"
:label="tag"
:value="tag"
/>
</el-select>
</div>
</EdSection>
</template>
<style scoped>
.geometry-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--ed-space-4);
}
.geo-full {
grid-column: 1 / -1;
}
.tag-number-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.tag-label {
font-size: 12px;
color: var(--ed-text-secondary, #606266);
flex-shrink: 0;
}
.tag-select {
flex: 1;
}
/* 覆盖 el-select 样式使其匹配编辑器风格 */
.tag-select :deep(.el-input__wrapper) {
background: transparent;
box-shadow: none;
border-bottom: 2px solid transparent;
border-radius: var(--ed-radius-sm, 4px);
height: var(--ed-control-height, 24px);
padding: 0 6px;
font-size: var(--ed-font-md, 12px);
transition: background var(--ed-transition-fast, 0.15s), border-color var(--ed-transition-fast, 0.15s);
}
.tag-select :deep(.el-input__wrapper:hover) {
background: var(--ed-bg-hover, #f5f5f5);
box-shadow: none;
}
.tag-select :deep(.el-input__wrapper.is-focus) {
background: var(--ed-bg-raised, #fafafa);
box-shadow: none;
border-bottom-color: var(--ed-accent, #1677ff);
}
.tag-select :deep(.el-input__inner) {
font-family: var(--ed-font);
font-size: var(--ed-font-md, 12px);
color: var(--ed-text-primary, #1a1a1a);
}
.tag-select :deep(.el-input__inner::placeholder) {
color: var(--ed-text-tertiary, #8c8c8c);
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdInput, EdNumberInput, EdSection } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer } = useCanvasState()
const { updateField } = usePropertyContext()
const prefix = computed(() => String(selectedLayer.value?.config?.prefix ?? ''))
const suffix = computed(() => String(selectedLayer.value?.config?.suffix ?? ''))
const decimals = computed(() => Number(selectedLayer.value?.config?.decimals ?? 2))
</script>
<template>
<EdSection title="数值格式">
<div class="format-grid">
<EdInput
:model-value="prefix"
placeholder="前缀"
@update:model-value="updateField('config.prefix', $event)"
/>
<EdInput
:model-value="suffix"
placeholder="后缀"
@update:model-value="updateField('config.suffix', $event)"
/>
<EdNumberInput
label="D"
:model-value="decimals"
:min="0"
:max="10"
:step="1"
@update:model-value="updateField('config.decimals', $event)"
/>
</div>
</EdSection>
</template>
<style scoped>
.format-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--ed-space-4);
}
.format-grid > :last-child {
grid-column: 1 / -1;
}
</style>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdColorInput, EdNumberInput, EdSection, EdToggle } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer } = useCanvasState()
const { updateField } = usePropertyContext()
const shadowEnabled = computed(() => Boolean(selectedLayer.value?.style?.shadow?.enabled))
const shadowColor = computed(() => String(selectedLayer.value?.style?.shadow?.color ?? ''))
const shadowBlur = computed(() => Number(selectedLayer.value?.style?.shadow?.blur ?? 0))
const shadowOffsetX = computed(() => Number(selectedLayer.value?.style?.shadow?.offsetX ?? 0))
const shadowOffsetY = computed(() => Number(selectedLayer.value?.style?.shadow?.offsetY ?? 0))
const shadowOpacity = computed(() => {
const raw = selectedLayer.value?.style?.shadow?.opacity
return typeof raw === 'number' ? Math.round(raw * 100) : 100
})
const shadowInset = computed(() => Boolean(selectedLayer.value?.style?.shadow?.inset))
const supportsTextShadow = computed(() => {
const type = selectedLayer.value?.type
return type === 'text' || type === 'number'
})
const textShadowEnabled = computed(() => Boolean(selectedLayer.value?.style?.textShadow?.enabled))
const textShadowColor = computed(() => String(selectedLayer.value?.style?.textShadow?.color ?? ''))
const textShadowBlur = computed(() => Number(selectedLayer.value?.style?.textShadow?.blur ?? 0))
const textShadowOffsetX = computed(() => Number(selectedLayer.value?.style?.textShadow?.offsetX ?? 0))
const textShadowOffsetY = computed(() => Number(selectedLayer.value?.style?.textShadow?.offsetY ?? 0))
</script>
<template>
<div>
<EdSection title="阴影" :default-collapsed="true">
<template #actions>
<EdToggle
:model-value="shadowEnabled"
@update:model-value="updateField('style.shadow.enabled', $event)"
/>
</template>
<div v-if="shadowEnabled" class="shadow-grid">
<EdColorInput
:model-value="shadowColor"
placeholder="无"
@update:model-value="updateField('style.shadow.color', $event)"
/>
<EdNumberInput
label="B"
:model-value="shadowBlur"
:min="0"
:step="1"
@update:model-value="updateField('style.shadow.blur', $event)"
/>
<EdNumberInput
label="X"
:model-value="shadowOffsetX"
:step="1"
@update:model-value="updateField('style.shadow.offsetX', $event)"
/>
<EdNumberInput
label="Y"
:model-value="shadowOffsetY"
:step="1"
@update:model-value="updateField('style.shadow.offsetY', $event)"
/>
<EdNumberInput
:model-value="shadowOpacity"
:min="0" :max="100" :step="1"
label="%"
@update:model-value="updateField('style.shadow.opacity', $event / 100)"
/>
<div class="shadow-inset-row">
<span class="shadow-label">内阴影</span>
<EdToggle
:model-value="shadowInset"
@update:model-value="updateField('style.shadow.inset', $event)"
/>
</div>
</div>
</EdSection>
<EdSection v-if="supportsTextShadow" title="文本阴影" :default-collapsed="true">
<template #actions>
<EdToggle
:model-value="textShadowEnabled"
@update:model-value="updateField('style.textShadow.enabled', $event)"
/>
</template>
<div v-if="textShadowEnabled" class="shadow-grid">
<EdColorInput
:model-value="textShadowColor"
placeholder="无"
@update:model-value="updateField('style.textShadow.color', $event)"
/>
<EdNumberInput
label="B" :model-value="textShadowBlur" :min="0" :step="1"
@update:model-value="updateField('style.textShadow.blur', $event)"
/>
<EdNumberInput
label="X" :model-value="textShadowOffsetX" :step="1"
@update:model-value="updateField('style.textShadow.offsetX', $event)"
/>
<EdNumberInput
label="Y" :model-value="textShadowOffsetY" :step="1"
@update:model-value="updateField('style.textShadow.offsetY', $event)"
/>
</div>
</EdSection>
</div>
</template>
<style scoped>
.shadow-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--ed-space-4);
}
.shadow-inset-row {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
}
.shadow-label {
font-size: 12px;
color: var(--ed-text-secondary, #606266);
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { EdSelectOption } from '../../../../controls'
import { computed } from 'vue'
import { EdColorInput, EdNumberInput, EdSection, EdSelect } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer } = useCanvasState()
const { updateField } = usePropertyContext()
const borderColor = computed(() => String(selectedLayer.value?.style?.border?.color ?? ''))
const borderWidth = computed(() => Number(selectedLayer.value?.style?.border?.width ?? 0))
const borderStyle = computed(() => String(selectedLayer.value?.style?.border?.style ?? 'solid'))
const borderRadius = computed(() => Number(selectedLayer.value?.config?.radius ?? 0))
const styleOptions: EdSelectOption[] = [
{ label: '实线', value: 'solid' },
{ label: '虚线', value: 'dashed' },
{ label: '点线', value: 'dotted' },
{ label: '无', value: 'none' },
]
</script>
<template>
<EdSection title="边框" :default-collapsed="true">
<div class="stroke-grid">
<EdColorInput
:model-value="borderColor"
placeholder="无"
@update:model-value="updateField('style.border.color', $event)"
/>
<EdNumberInput
label="W"
:model-value="borderWidth"
:min="0"
:step="1"
@update:model-value="updateField('style.border.width', $event)"
/>
<EdSelect
:model-value="borderStyle"
:options="styleOptions"
@update:model-value="updateField('style.border.style', $event)"
/>
<EdNumberInput
label="R"
:model-value="borderRadius"
:min="0"
:step="1"
@update:model-value="updateField('style.border.radius', $event)"
/>
</div>
</EdSection>
</template>
<style scoped>
.stroke-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--ed-space-4);
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import type { EdSelectOption } from '../../../../controls'
import { computed } from 'vue'
import { EdColorInput, EdIconButton, EdNumberInput, EdSection, EdSelect } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer } = useCanvasState()
const { updateField } = usePropertyContext()
const isNumber = computed(() => selectedLayer.value?.type === 'number')
const textColor = computed(() => String(selectedLayer.value?.config?.textColor ?? ''))
const fontSize = computed(() => Number(selectedLayer.value?.config?.fontSize ?? (isNumber.value ? 24 : 18)))
const fontWeight = computed(() => Number(selectedLayer.value?.config?.fontWeight ?? (isNumber.value ? 700 : 500)))
const fontStyle = computed(() => String(selectedLayer.value?.config?.fontStyle ?? 'normal'))
const textDecoration = computed(() => String(selectedLayer.value?.config?.textDecoration ?? 'none'))
const textAlign = computed(() => String(selectedLayer.value?.config?.align ?? 'center'))
const verticalAlign = computed(() => String(selectedLayer.value?.config?.verticalAlign ?? 'middle'))
const fontWeightOptions: EdSelectOption[] = [
{ label: '细体', value: 300 },
{ label: '常规', value: 400 },
{ label: '中等', value: 500 },
{ label: '半粗', value: 600 },
{ label: '粗体', value: 700 },
]
</script>
<template>
<EdSection title="文字">
<div class="text-grid">
<div class="text-color-row">
<EdColorInput
:model-value="textColor"
placeholder="自动"
@update:model-value="updateField('config.textColor', $event)"
/>
</div>
<div class="text-detail-row">
<EdNumberInput
label="A"
:model-value="fontSize"
:min="10"
:step="1"
@update:model-value="updateField('config.fontSize', $event)"
/>
<EdSelect
:model-value="fontWeight"
:options="fontWeightOptions"
@update:model-value="updateField('config.fontWeight', $event)"
/>
</div>
<div class="text-style-row">
<EdIconButton
icon="fa-solid fa-italic"
:active="fontStyle === 'italic'"
title="斜体"
@click="updateField('config.fontStyle', fontStyle === 'italic' ? 'normal' : 'italic')"
/>
<EdIconButton
icon="fa-solid fa-underline"
:active="textDecoration === 'underline'"
title="下划线"
@click="updateField('config.textDecoration', textDecoration === 'underline' ? 'none' : 'underline')"
/>
</div>
<div class="text-align-row">
<div class="align-group">
<EdIconButton
icon="fa-solid fa-align-left"
:active="textAlign === 'left'"
title="左对齐"
@click="updateField('config.align', 'left')"
/>
<EdIconButton
icon="fa-solid fa-align-center"
:active="textAlign === 'center'"
title="居中"
@click="updateField('config.align', 'center')"
/>
<EdIconButton
icon="fa-solid fa-align-right"
:active="textAlign === 'right'"
title="右对齐"
@click="updateField('config.align', 'right')"
/>
</div>
<div class="align-group">
<EdIconButton
icon="fa-solid fa-arrows-up-to-line"
:active="verticalAlign === 'top'"
title="顶部对齐"
@click="updateField('config.verticalAlign', 'top')"
/>
<EdIconButton
icon="fa-solid fa-arrows-left-right-to-line fa-rotate-90"
:active="verticalAlign === 'middle'"
title="垂直居中"
@click="updateField('config.verticalAlign', 'middle')"
/>
<EdIconButton
icon="fa-solid fa-arrows-down-to-line"
:active="verticalAlign === 'bottom'"
title="底部对齐"
@click="updateField('config.verticalAlign', 'bottom')"
/>
</div>
</div>
</div>
</EdSection>
</template>
<style scoped>
.text-grid {
display: flex;
flex-direction: column;
gap: var(--ed-space-4);
}
.text-color-row {
width: 100%;
}
.text-detail-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--ed-space-4);
}
.text-style-row {
display: flex;
gap: var(--ed-space-1);
}
.text-align-row {
display: flex;
justify-content: space-between;
}
.align-group {
display: flex;
gap: var(--ed-space-1);
}
</style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdInput, EdSection } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer } = useCanvasState()
const { updateField } = usePropertyContext()
const content = computed(() => String(selectedLayer.value?.config?.content ?? ''))
</script>
<template>
<EdSection title="文本内容">
<EdInput
:model-value="content"
placeholder="输入文本内容"
@update:model-value="updateField('config.content', $event)"
/>
</EdSection>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EdColorInput, EdInput, EdNumberInput, EdSection } from '../../../../controls'
import { useCanvasState } from '../../../context/state'
import { usePropertyContext } from '../composables/usePropertyContext'
const { selectedLayer } = useCanvasState()
const { updateField } = usePropertyContext()
const bgColor = computed(() => String(selectedLayer.value?.config?.bgColor ?? '#1a1a2e'))
const textColor = computed(() => String(selectedLayer.value?.config?.textColor ?? '#ffffff'))
const decimalPlaces = computed(() => Number(selectedLayer.value?.config?.decimalPlaces ?? 2))
const description = computed(() => String(selectedLayer.value?.config?.description ?? ''))
</script>
<template>
<EdSection title="阀门控制器">
<div class="valve-config-grid">
<div class="valve-config-item">
<span class="valve-config-label">描述</span>
<EdInput
:model-value="description"
placeholder="设备描述"
@update:model-value="updateField('config.description', $event)"
/>
</div>
<div class="valve-config-item">
<span class="valve-config-label">小数位数</span>
<EdNumberInput
:model-value="decimalPlaces"
:min="0"
:max="6"
:step="1"
@update:model-value="updateField('config.decimalPlaces', $event)"
/>
</div>
<div class="valve-config-item">
<span class="valve-config-label">背景色</span>
<EdColorInput :model-value="bgColor" @update:model-value="updateField('config.bgColor', $event)" />
</div>
<div class="valve-config-item">
<span class="valve-config-label">文字色</span>
<EdColorInput :model-value="textColor" @update:model-value="updateField('config.textColor', $event)" />
</div>
</div>
</EdSection>
</template>
<style scoped>
.valve-config-grid { display: flex; flex-direction: column; gap: var(--ed-space-4); }
.valve-config-item { display: flex; align-items: center; gap: 8px; }
.valve-config-label { font-size: 12px; color: var(--ed-text-secondary, #606266); min-width: 48px; flex-shrink: 0; }
</style>

View File

@@ -0,0 +1,207 @@
<script setup lang="ts">
import { onBeforeUnmount, shallowRef, watch } from 'vue'
export interface ContextMenuItem {
key: string
label?: string
type?: 'item' | 'separator'
disabled?: boolean
danger?: boolean
checked?: boolean
shortcut?: string
}
const props = defineProps<{
visible: boolean
x: number
y: number
items: ContextMenuItem[]
}>()
const emit = defineEmits<{
close: []
select: [key: string]
}>()
const menuRef = shallowRef<HTMLElement | null>(null)
function onSelect(key: string, disabled?: boolean) {
if (disabled) {
return
}
emit('select', key)
emit('close')
}
function isEventInsideMenu(event: Event) {
const menuElement = menuRef.value
if (!menuElement) {
return false
}
return event.composedPath().includes(menuElement)
}
function onWindowPointerDown(event: PointerEvent) {
if (isEventInsideMenu(event)) {
return
}
emit('close')
}
function onWindowContextMenu(event: MouseEvent) {
if (isEventInsideMenu(event)) {
return
}
emit('close')
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
emit('close')
}
}
watch(
() => props.visible,
(visible) => {
if (visible) {
window.addEventListener('pointerdown', onWindowPointerDown, true)
window.addEventListener('contextmenu', onWindowContextMenu, true)
window.addEventListener('keydown', onWindowKeydown)
return
}
window.removeEventListener('pointerdown', onWindowPointerDown, true)
window.removeEventListener('contextmenu', onWindowContextMenu, true)
window.removeEventListener('keydown', onWindowKeydown)
},
{ immediate: true },
)
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onWindowPointerDown, true)
window.removeEventListener('contextmenu', onWindowContextMenu, true)
window.removeEventListener('keydown', onWindowKeydown)
})
</script>
<template>
<teleport to="body">
<div
v-if="visible"
ref="menuRef"
class="editor-context-menu"
:style="{ left: `${x}px`, top: `${y}px` }"
@pointerdown.stop
@contextmenu.prevent.stop
>
<template v-for="item in items" :key="item.key">
<div
v-if="item.type === 'separator'"
class="context-menu-separator"
/>
<button
v-else
type="button"
class="context-menu-item"
:class="{ danger: item.danger, disabled: item.disabled, checked: item.checked }"
:disabled="item.disabled"
@click.stop="onSelect(item.key, item.disabled)"
>
<span class="context-menu-item__label">
<i v-if="item.checked" class="fa-solid fa-check context-menu-item__check" />
<span>{{ item.label }}</span>
</span>
<span v-if="item.shortcut" class="context-menu-item__shortcut">{{ item.shortcut }}</span>
</button>
</template>
</div>
</teleport>
</template>
<style lang="scss" scoped>
.editor-context-menu {
position: fixed;
min-width: 172px;
max-width: 196px;
max-height: min(400px, 80vh);
overflow-y: auto;
padding: 4px;
border-radius: var(--ed-radius-lg);
border: 1px solid var(--ed-border);
background: var(--ed-bg-raised);
box-shadow: var(--ed-shadow-md);
z-index: 1200;
display: flex;
flex-direction: column;
gap: 2px;
}
.context-menu-separator {
height: 1px;
margin: 4px 8px;
background: var(--ed-border);
}
.context-menu-item {
min-height: 28px;
padding: 0 12px;
border: none;
border-radius: var(--ed-radius-md);
background: transparent;
color: var(--ed-text-primary);
font-size: var(--ed-font-sm);
font-weight: var(--ed-font-weight-normal);
line-height: 1;
letter-spacing: 0;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color var(--ed-transition-fast), color var(--ed-transition-fast);
}
.context-menu-item:hover:not(.disabled) {
background: var(--ed-bg-hover);
color: var(--ed-text-primary);
}
.context-menu-item__label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.context-menu-item__check {
font-size: 11px;
color: var(--ed-accent);
}
.context-menu-item__shortcut {
margin-left: 12px;
font-size: var(--ed-font-xs);
color: var(--ed-text-tertiary);
font-weight: var(--ed-font-weight-normal);
}
.context-menu-item.danger {
color: var(--ed-danger);
}
.context-menu-item.danger:hover:not(.disabled) {
background: rgba(245, 34, 45, 0.06);
color: var(--ed-danger);
}
.context-menu-item.disabled {
color: var(--ed-text-disabled);
cursor: not-allowed;
background: transparent;
}
.context-menu-item.disabled .context-menu-item__shortcut {
color: var(--ed-text-disabled);
}
</style>

View File

@@ -0,0 +1,703 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, shallowRef, watch } from 'vue'
import { getCanvasByIdApi } from '@/api'
import { MAX_CANVAS_SIZE, MIN_CANVAS_HEIGHT, MIN_CANVAS_WIDTH } from '@/constants'
import pinia, { useCanvasStore, useLayerStore } from '@/stores'
import { cloneData, extractLayers } from '@/utils'
import { useLayerSelection } from '../../../composables/layer'
import { useLayerPersistence } from '../../../composables/persistence'
import { useCanvasInject, useCanvasRuntimeInject, useEditorUIInject } from '../../../context'
import { useCanvasState } from '../../../context/state'
import { listenEditorEmitter, setEditorEmitter } from '../../../emitter'
import { unwrapElement } from '../../../utils'
export function useCanvasStage() {
// 这里同时负责“底图渲染”和“视口控制”,因此常量集中定义在入口。
const DEFAULT_CANVAS_WIDTH_RATIO = 0.6
const STAGE_BACKGROUND = '#EEF1F5'
const DEFAULT_BASE_LAYER_BACKGROUND = '#FFFFFF'
const MIN_VIEW_SCALE = 0.1
const MAX_VIEW_SCALE = 100
const { stageRef, getCanvasElement, getWrapperElement } = useCanvasInject()
const { setBackgroundAverageColor } = useCanvasRuntimeInject()
const layerStore = useLayerStore(pinia)
const { clipboard } = useEditorUIInject()
const imageRef = shallowRef<HTMLImageElement | null>(null)
const { canvasId, canvasView, activeCanvasThumbnail, isHydratingCanvas, canvasInfo } = useCanvasState()
let resizeObserver: ResizeObserver | null = null
let observedWrapper: HTMLElement | null = null
// 平移使用屏幕坐标记录起点,再映射回当前视口偏移。
const panState = reactive({
active: false,
startClientX: 0,
startClientY: 0,
startOffsetX: 0,
startOffsetY: 0,
})
const wrapperStyle = computed(() => ({
backgroundColor: STAGE_BACKGROUND,
}))
function updateCanvasSize() {
const wrapper = getWrapperElement()
const canvas = getCanvasElement()
if (!wrapper || !canvas) {
return
}
const rect = wrapper.getBoundingClientRect()
const width = Math.max(1, Math.floor(rect.width))
const height = Math.max(1, Math.floor(rect.height))
if (canvas.width !== width) {
canvas.width = width
}
if (canvas.height !== height) {
canvas.height = height
}
canvasView.value.canvasWidth = width
canvasView.value.canvasHeight = height
updateLayout()
}
function getDefaultScale() {
// 默认缩放优先保证宽度可见,再受高度约束,避免初次进入时铺满过头。
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight || !canvasView.value.canvasWidth || !canvasView.value.canvasHeight) {
return 1
}
const targetScaleByWidth = (canvasView.value.canvasWidth * DEFAULT_CANVAS_WIDTH_RATIO) / canvasView.value.imageWidth
const maxScaleByHeight = canvasView.value.canvasHeight / canvasView.value.imageHeight
const scale = Math.min(targetScaleByWidth, maxScaleByHeight)
return Number.isFinite(scale) && scale > 0 ? scale : 1
}
function centerBaseLayer() {
const drawWidth = canvasView.value.imageWidth * canvasView.value.scale
const drawHeight = canvasView.value.imageHeight * canvasView.value.scale
canvasView.value.offsetX = Math.round((canvasView.value.canvasWidth - drawWidth) / 2)
canvasView.value.offsetY = Math.round((canvasView.value.canvasHeight - drawHeight) / 2)
}
function resetViewToDefault() {
if (!getCanvasElement()) {
return
}
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight || !canvasView.value.canvasWidth || !canvasView.value.canvasHeight) {
canvasView.value.scale = 1
canvasView.value.offsetX = 0
canvasView.value.offsetY = 0
drawCanvas()
return
}
canvasView.value.scale = getDefaultScale()
centerBaseLayer()
drawCanvas()
}
function updateLayout(options?: { resetView?: boolean }) {
if (!getCanvasElement()) {
return
}
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight || !canvasView.value.canvasWidth || !canvasView.value.canvasHeight) {
// 尺寸不足时也要触发一次重绘,避免保留上一张画布残影。
drawCanvas()
return
}
if (options?.resetView) {
resetViewToDefault()
return
}
drawCanvas()
}
function isBaseLayerInViewport() {
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight || !canvasView.value.canvasWidth || !canvasView.value.canvasHeight || canvasView.value.scale <= 0) {
return false
}
const drawWidth = canvasView.value.imageWidth * canvasView.value.scale
const drawHeight = canvasView.value.imageHeight * canvasView.value.scale
const left = canvasView.value.offsetX
const top = canvasView.value.offsetY
const right = left + drawWidth
const bottom = top + drawHeight
return right > 0 && bottom > 0 && left < canvasView.value.canvasWidth && top < canvasView.value.canvasHeight
}
function drawCanvas() {
const canvas = getCanvasElement()
if (!canvas) {
return
}
const ctx = canvas.getContext('2d')
if (!ctx) {
return
}
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = STAGE_BACKGROUND
ctx.fillRect(0, 0, canvas.width, canvas.height)
const drawWidth = canvasView.value.imageWidth * canvasView.value.scale
const drawHeight = canvasView.value.imageHeight * canvasView.value.scale
const image = imageRef.value
// 每次重绘都先完整铺背景,避免缩放和平移后出现旧像素残留。
if (drawWidth > 0 && drawHeight > 0) {
ctx.fillStyle = canvasView.value.background || DEFAULT_BASE_LAYER_BACKGROUND
ctx.fillRect(canvasView.value.offsetX, canvasView.value.offsetY, drawWidth, drawHeight)
if (image) {
ctx.drawImage(image, canvasView.value.offsetX, canvasView.value.offsetY, drawWidth, drawHeight)
}
else {
// 无底图时保留一个逻辑画布区域,便于后续设置宽高。
ctx.strokeStyle = '#D0D7DE'
ctx.lineWidth = 1
ctx.strokeRect(canvasView.value.offsetX, canvasView.value.offsetY, drawWidth, drawHeight)
}
}
}
function sampleImageAverageColor(image: HTMLImageElement) {
// 采样缩小后的像素均值,用来给面板和文字自动选对比色。
const width = Math.max(1, Math.min(32, image.naturalWidth || image.width || 1))
const height = Math.max(1, Math.min(32, image.naturalHeight || image.height || 1))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) {
return null
}
ctx.drawImage(image, 0, 0, width, height)
const imageData = ctx.getImageData(0, 0, width, height).data
let red = 0
let green = 0
let blue = 0
let alphaCount = 0
for (let index = 0; index < imageData.length; index += 4) {
const alpha = imageData[index + 3] / 255
if (alpha <= 0) {
continue
}
red += imageData[index] * alpha
green += imageData[index + 1] * alpha
blue += imageData[index + 2] * alpha
alphaCount += alpha
}
if (!alphaCount) {
return null
}
const toHex = (value: number) => Math.round(value / alphaCount).toString(16).padStart(2, '0')
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`
}
function scaleNumericValue(value: unknown, factor: number) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return value
}
return Math.max(0, Number((value * factor).toFixed(2)))
}
function scaleLayersToBaseLayer(nextWidth: number, nextHeight: number, prevWidth: number, prevHeight: number) {
if (!layerStore.layerList.length || prevWidth <= 0 || prevHeight <= 0 || isHydratingCanvas.value) {
return
}
const scaleX = nextWidth / prevWidth
const scaleY = nextHeight / prevHeight
if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY) || (!scaleX && !scaleY)) {
return
}
if (Math.abs(scaleX - 1) < 0.0001 && Math.abs(scaleY - 1) < 0.0001) {
return
}
const typographyScale = Math.sqrt(scaleX * scaleY)
// 组件几何属性按 X/Y 缩放,文字和阴影模糊这类视觉属性按综合比例缩放。
const nextLayers = layerStore.layerList.map((layer) => {
const nextLayer = cloneData(layer)
nextLayer.x = Math.round(nextLayer.x * scaleX)
nextLayer.y = Math.round(nextLayer.y * scaleY)
nextLayer.width = Math.max(24, Math.round(nextLayer.width * scaleX))
nextLayer.height = Math.max(24, Math.round(nextLayer.height * scaleY))
const nextConfig = typeof nextLayer.config === 'object' && nextLayer.config
? nextLayer.config as Record<string, unknown>
: undefined
if (nextConfig) {
nextConfig.fontSize = scaleNumericValue(nextConfig.fontSize, typographyScale)
nextConfig.strokeWidth = scaleNumericValue(nextConfig.strokeWidth, typographyScale)
nextConfig.radius = scaleNumericValue(nextConfig.radius, typographyScale)
nextConfig.shadowBlur = scaleNumericValue(nextConfig.shadowBlur, typographyScale)
nextConfig.shadowOffsetX = scaleNumericValue(nextConfig.shadowOffsetX, scaleX)
nextConfig.shadowOffsetY = scaleNumericValue(nextConfig.shadowOffsetY, scaleY)
}
const nextStyle = typeof nextLayer.style === 'object' && nextLayer.style
? nextLayer.style as Record<string, any>
: undefined
if (nextStyle?.text) {
nextStyle.text.fontSize = scaleNumericValue(nextStyle.text.fontSize, typographyScale)
}
if (nextStyle?.border) {
nextStyle.border.width = scaleNumericValue(nextStyle.border.width, typographyScale)
nextStyle.border.radius = scaleNumericValue(nextStyle.border.radius, typographyScale)
}
if (nextStyle?.shadow) {
nextStyle.shadow.blur = scaleNumericValue(nextStyle.shadow.blur, typographyScale)
nextStyle.shadow.offsetX = scaleNumericValue(nextStyle.shadow.offsetX, scaleX)
nextStyle.shadow.offsetY = scaleNumericValue(nextStyle.shadow.offsetY, scaleY)
}
return nextLayer
})
layerStore.setLayerList(nextLayers, { preserveSelection: true })
}
function ensureObserver() {
const wrapper = getWrapperElement()
if (!wrapper) {
return
}
// ResizeObserver 负责容器尺寸变化时重算画布尺寸。
if (!resizeObserver) {
resizeObserver = new ResizeObserver(() => {
updateCanvasSize()
})
}
if (observedWrapper && observedWrapper !== wrapper) {
resizeObserver.unobserve(observedWrapper)
}
if (observedWrapper !== wrapper) {
resizeObserver.observe(wrapper)
observedWrapper = wrapper
}
}
async function loadBackground(
imageUrl: string,
options?: { width?: number, height?: number, lockAspectRatio?: boolean, resetView?: boolean },
): Promise<void> {
// 底图加载完成后,同时同步自然尺寸、取样颜色和逻辑画布尺寸。
const image = new Image()
image.src = imageUrl
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = () => reject(new Error('底图加载失败'))
})
imageRef.value = image
const averageColor = sampleImageAverageColor(image)
canvasView.value.backgroundAverageColor = averageColor
setBackgroundAverageColor(averageColor)
canvasView.value.imageNaturalWidth = image.naturalWidth
canvasView.value.imageNaturalHeight = image.naturalHeight
const safeWidth = Number(options?.width)
const safeHeight = Number(options?.height)
const nextWidth = Number.isFinite(safeWidth) && safeWidth > 0
? Math.round(safeWidth)
: image.naturalWidth
const nextHeight = Number.isFinite(safeHeight) && safeHeight > 0
? Math.round(safeHeight)
: image.naturalHeight
setBaseLayerSize({ width: nextWidth, height: nextHeight, options: { resetView: options?.resetView } })
if (typeof options?.lockAspectRatio === 'boolean') {
canvasView.value.backgroundLockAspectRatio = options.lockAspectRatio
}
}
function clearBackground(width = 0, height = 0, options?: { resetView?: boolean }) {
// 清底图时保留逻辑宽高的入口,便于“纯色画布”场景继续编辑。
imageRef.value = null
canvasView.value.backgroundAverageColor = null
setBackgroundAverageColor(null)
canvasView.value.imageNaturalWidth = 0
canvasView.value.imageNaturalHeight = 0
setBaseLayerSize({ width, height, options })
}
// 批量更新背景图层尺寸,避免分别设置宽高引起多次重排抖动。
function setBaseLayerSize({ width, height, options }: { width: number, height: number, options?: { resetView?: boolean } }) {
const rawWidth = Math.round(width)
const rawHeight = Math.round(height)
const prevWidth = canvasView.value.imageWidth
const prevHeight = canvasView.value.imageHeight
// 当未加载任何画布数据时允许清空尺寸,其余场景统一走画布尺寸边界。
if (rawWidth <= 0 && rawHeight <= 0) {
canvasView.value.imageWidth = 0
canvasView.value.imageHeight = 0
updateLayout({ resetView: options?.resetView })
return
}
const nextWidth = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_WIDTH, rawWidth))
const nextHeight = Math.min(MAX_CANVAS_SIZE, Math.max(MIN_CANVAS_HEIGHT, rawHeight))
scaleLayersToBaseLayer(nextWidth, nextHeight, prevWidth, prevHeight)
canvasView.value.imageWidth = nextWidth
canvasView.value.imageHeight = nextHeight
updateLayout({ resetView: options?.resetView })
}
listenEditorEmitter('baseLayer:setSize', setBaseLayerSize)
async function setup() {
watch(
() => stageRef.value,
(next) => {
stageRef.value = next
if (next) {
// stage 引用就绪后,立即同步一次画布尺寸。
updateCanvasSize()
ensureObserver()
}
},
{ immediate: true },
)
watch(
() => unwrapElement(stageRef.value?.wrapperRef),
(wrapper) => {
if (!wrapper) {
return
}
// wrapper DOM 就绪后,再测量一次尺寸。
updateCanvasSize()
ensureObserver()
},
{ immediate: true },
)
await nextTick()
updateCanvasSize()
ensureObserver()
requestAnimationFrame(() => {
updateCanvasSize()
})
}
watch(
() => unwrapElement(stageRef.value?.wrapperRef),
(wrapper) => {
if (!wrapper) {
return
}
updateCanvasSize()
},
)
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onPanPointerMove)
window.removeEventListener('pointerup', onPanPointerUp)
if (resizeObserver && observedWrapper) {
resizeObserver.unobserve(observedWrapper)
}
resizeObserver?.disconnect()
resizeObserver = null
observedWrapper = null
})
function setScale(newScale: number, anchor?: { clientX: number, clientY: number }) {
const clampedScale = Math.min(Math.max(newScale, MIN_VIEW_SCALE), MAX_VIEW_SCALE)
const prevScale = canvasView.value.scale
if (!Number.isFinite(clampedScale) || prevScale <= 0 || clampedScale === prevScale) {
return
}
if (anchor) {
// 以鼠标所在点为缩放锚点,保证缩放前后的视觉焦点不漂移。
const canvas = getCanvasElement()
if (canvas) {
const rect = canvas.getBoundingClientRect()
const canvasX = anchor.clientX - rect.left
const canvasY = anchor.clientY - rect.top
const imageX = (canvasX - canvasView.value.offsetX) / prevScale
const imageY = (canvasY - canvasView.value.offsetY) / prevScale
canvasView.value.scale = clampedScale
canvasView.value.offsetX = canvasX - imageX * clampedScale
canvasView.value.offsetY = canvasY - imageY * clampedScale
drawCanvas()
return
}
}
canvasView.value.scale = clampedScale
drawCanvas()
}
function onPanPointerMove(event: PointerEvent) {
if (!panState.active) {
return
}
canvasView.value.offsetX = panState.startOffsetX + (event.clientX - panState.startClientX)
canvasView.value.offsetY = panState.startOffsetY + (event.clientY - panState.startClientY)
drawCanvas()
}
function onPanPointerUp() {
panState.active = false
window.removeEventListener('pointermove', onPanPointerMove)
window.removeEventListener('pointerup', onPanPointerUp)
setEditorEmitter('propertyPanel:resume')
}
function beginPan(event: PointerEvent) {
if (!getCanvasElement()) {
return false
}
event.preventDefault()
// 拖动画布期间暂时隐藏属性面板,避免遮挡和跟随重算。
setEditorEmitter('propertyPanel:suspend')
panState.active = true
panState.startClientX = event.clientX
panState.startClientY = event.clientY
panState.startOffsetX = canvasView.value.offsetX
panState.startOffsetY = canvasView.value.offsetY
window.addEventListener('pointermove', onPanPointerMove)
window.addEventListener('pointerup', onPanPointerUp)
return true
}
function panBy(deltaX: number, deltaY: number) {
if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) {
return
}
if (!deltaX && !deltaY) {
return
}
canvasView.value.offsetX += deltaX
canvasView.value.offsetY += deltaY
drawCanvas()
}
function centerTarget(bounds: { x: number, y: number, width: number, height: number }) {
if (!Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) || !Number.isFinite(bounds.width) || !Number.isFinite(bounds.height)) {
return
}
if (!canvasView.value.canvasWidth || !canvasView.value.canvasHeight || canvasView.value.scale <= 0) {
return
}
const targetCenterX = bounds.x + bounds.width / 2
const targetCenterY = bounds.y + bounds.height / 2
canvasView.value.offsetX = Math.round(canvasView.value.canvasWidth / 2 - targetCenterX * canvasView.value.scale)
canvasView.value.offsetY = Math.round(canvasView.value.canvasHeight / 2 - targetCenterY * canvasView.value.scale)
drawCanvas()
}
listenEditorEmitter('viewport:setScale', ({ newScale, anchor }) => {
setScale(newScale, anchor)
})
listenEditorEmitter('viewport:panBy', ({ deltaX, deltaY }) => {
panBy(deltaX, deltaY)
})
listenEditorEmitter('viewport:beginPan', (event) => {
beginPan(event)
})
listenEditorEmitter('viewport:centerTarget', (bounds) => {
centerTarget(bounds)
})
function zoomIn() {
setScale(canvasView.value.scale * 1.1)
}
function zoomOut() {
setScale(canvasView.value.scale / 1.1)
}
function resetZoom() {
resetViewToDefault()
}
function resetCanvasPresentation(options?: { resetView?: boolean }) {
useLayerSelection().replaceLayers([])
layerStore.deactivateBaseLayerSelection()
clipboard.resetClipboard()
activeCanvasThumbnail.value = ''
canvasInfo.value = null
canvasView.value.backgroundLockAspectRatio = true
canvasView.value.background = DEFAULT_BASE_LAYER_BACKGROUND
canvasView.value.backgroundAverageColor = null
setBackgroundAverageColor(null)
clearBackground(0, 0, { resetView: options?.resetView })
}
// 按画布 ID 从 request 层加载组件和底图
async function hydrateCanvasData(canvasId: string, hydrateOptions?: { forceResetView?: boolean }) {
// 恢复画布时以服务端详情为真值,统一重建底图、尺寸和组件列表。
const detail = canvasInfo.value?.id === canvasId
? canvasInfo.value
: await getCanvasByIdApi(canvasId)
canvasInfo.value = detail
if (!detail) {
resetCanvasPresentation({ resetView: hydrateOptions?.forceResetView ?? true })
return
}
const nextLayers = extractLayers(detail.components as unknown[])
const backgroundConfig = detail.config?.style?.background
const lockAspectRatio = typeof backgroundConfig?.lockAspectRatio === 'boolean'
? backgroundConfig.lockAspectRatio
: true
const backgroundColor = typeof backgroundConfig?.color === 'string' && backgroundConfig.color.trim()
? backgroundConfig.color.trim()
: '#FFFFFF'
const hasConfiguredBackgroundSize = typeof backgroundConfig?.width === 'number'
&& backgroundConfig.width > 0
&& typeof backgroundConfig?.height === 'number'
&& backgroundConfig.height > 0
const nextImageWidth = Math.max(MIN_CANVAS_WIDTH, hasConfiguredBackgroundSize ? backgroundConfig.width! : detail.width!)
const nextImageHeight = Math.max(MIN_CANVAS_HEIGHT, hasConfiguredBackgroundSize ? backgroundConfig.height! : detail.height!)
isHydratingCanvas.value = true
try {
// 切换画布时先完成底图更新,再一次性替换组件,减少中间态闪烁。
const nextThumbnail = detail.thumbnail || ''
activeCanvasThumbnail.value = nextThumbnail
canvasView.value.backgroundLockAspectRatio = lockAspectRatio
canvasView.value.background = backgroundColor
setBackgroundAverageColor(null)
if (detail.thumbnail) {
await loadBackground(detail.thumbnail, {
width: hasConfiguredBackgroundSize ? nextImageWidth : undefined,
height: hasConfiguredBackgroundSize ? nextImageHeight : undefined,
lockAspectRatio,
})
if (canvasView.value.imageWidth < MIN_CANVAS_WIDTH || canvasView.value.imageHeight < MIN_CANVAS_HEIGHT) {
setEditorEmitter('baseLayer:setSize', {
width: Math.max(MIN_CANVAS_WIDTH, canvasView.value.imageWidth),
height: Math.max(MIN_CANVAS_HEIGHT, canvasView.value.imageHeight),
})
}
}
else {
clearBackground(nextImageWidth, nextImageHeight)
}
useLayerSelection().replaceLayers(nextLayers)
clipboard.resetClipboard()
if (hydrateOptions?.forceResetView || !isBaseLayerInViewport()) {
resetViewToDefault()
}
}
finally {
isHydratingCanvas.value = false
}
}
onMounted(() => {
setup()
if (!canvasId.value) {
resetCanvasPresentation({ resetView: true })
return
}
// 首次进入编辑器时恢复画布
hydrateCanvasData(canvasId.value, { forceResetView: true })
})
const canvasStore = useCanvasStore(pinia)
watch(
() => canvasId.value,
async (nextCanvasId, prevCanvasId) => {
if (!nextCanvasId) {
resetCanvasPresentation({ resetView: true })
return
}
if (nextCanvasId === prevCanvasId) {
return
}
// 切换画布时重新恢复数据
await hydrateCanvasData(nextCanvasId)
},
)
watch(
() => canvasView.value.scale,
(scale) => {
const zoom = Math.max(1, Math.round(scale * 100))
canvasStore.setCanvasZoom(zoom)
},
{ immediate: true },
)
const { persistCanvasBaseLayer } = useLayerPersistence()
watch(
() => [canvasView.value.imageWidth, canvasView.value.imageHeight, canvasView.value.backgroundLockAspectRatio, canvasView.value.background],
() => {
// 底图尺寸、背景色或宽高比锁定状态变化后统一自动保存。
persistCanvasBaseLayer()
},
)
async function importBackground(source: string) {
activeCanvasThumbnail.value = source
try {
await loadBackground(source, {
lockAspectRatio: canvasView.value.backgroundLockAspectRatio,
})
if (canvasView.value.imageWidth < MIN_CANVAS_WIDTH || canvasView.value.imageHeight < MIN_CANVAS_HEIGHT) {
setBaseLayerSize(
{ width: Math.max(MIN_CANVAS_WIDTH, canvasView.value.imageWidth), height: Math.max(MIN_CANVAS_HEIGHT, canvasView.value.imageHeight) },
)
}
persistCanvasBaseLayer()
}
catch (error) {
console.error('[canvas] failed to import background', error)
}
}
async function onReplaceBackground(payload: { source: string }) {
if (!payload.source) {
return
}
await importBackground(payload.source)
layerStore.activateBaseLayerSelection()
}
listenEditorEmitter('propertyPanel:replaceBackground', onReplaceBackground)
function onRemoveBackground() {
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
activeCanvasThumbnail.value = ''
clearBackground(canvasView.value.imageWidth, canvasView.value.imageHeight)
persistCanvasBaseLayer()
layerStore.activateBaseLayerSelection()
}
listenEditorEmitter('propertyPanel:removeBackground', onRemoveBackground)
return {
setup,
canvasView,
wrapperStyle,
updateCanvasSize,
updateLayout,
getWrapperElement,
getCanvasElement,
zoomIn,
zoomOut,
resetZoom,
resetViewToDefault,
isBaseLayerInViewport,
setScale,
beginPan,
panBy,
loadBackground,
clearBackground,
setBaseLayerSize,
hydrateCanvasData,
}
}

View File

@@ -0,0 +1,525 @@
import type { Layer, ResizeHandle } from '../../../types'
import type { Rect } from './useSnapGuides'
import { onBeforeUnmount, reactive } from 'vue'
import { useLayerStore } from '@/stores'
import { useCanvasInject } from '../../../context'
import { useCanvasState } from '../../../context/state'
import { listenEditorEmitter, setEditorEmitter } from '../../../emitter'
import {
buildGroupPositionUpdates,
buildGroupRectUpdates,
getLayerGroupById,
} from '../../../grouping'
import { clamp } from '../../../utils'
import { useSnapGuides } from './useSnapGuides'
interface LayerRectSnapshot {
id: string
x: number
y: number
width: number
height: number
}
export function useLayerInteraction() {
const { canvasView, layerList, selectedLayerId, selectedLayerIds } = useCanvasState()
const { getCanvasElement } = useCanvasInject()
const layerStore = useLayerStore()
const { snapGuides, updateGuides, clearGuides, calcSnap, calcResizeSnap } = useSnapGuides()
const dragState = reactive({
active: false,
ids: [] as string[],
groupId: null as string | null,
startPointerX: 0,
startPointerY: 0,
startRects: new Map<string, LayerRectSnapshot>(),
startGroupBounds: null as Omit<LayerRectSnapshot, 'id'> | null,
})
const resizeState = reactive({
id: null as string | null,
groupId: null as string | null,
handle: null as ResizeHandle | null,
startX: 0,
startY: 0,
startLeft: 0,
startTop: 0,
startRight: 0,
startBottom: 0,
startRects: new Map<string, LayerRectSnapshot>(),
})
function getPointerImagePosition(event: PointerEvent) {
// 舞台交互全部在“底图坐标系”里计算,避免受缩放和平移影响。
const rect = getCanvasElement()?.getBoundingClientRect()
if (!rect || canvasView.value.scale <= 0) {
return null
}
const canvasX = event.clientX - rect.left
const canvasY = event.clientY - rect.top
const imageX = (canvasX - canvasView.value.offsetX) / canvasView.value.scale
const imageY = (canvasY - canvasView.value.offsetY) / canvasView.value.scale
return { imageX, imageY }
}
function isLocked(layer: Layer) {
return Boolean(layer.config?.locked)
}
function createSnapshotMap(ids: string[]) {
// 拖拽/缩放期间基于起始快照反复计算,避免连续累加造成误差。
return new Map(
layerList.value
.filter(entry => ids.includes(entry.id))
.map(entry => [
entry.id,
{
id: entry.id,
x: entry.x,
y: entry.y,
width: entry.width,
height: entry.height,
},
]),
)
}
function buildOtherRects(excludeIds: string[]): Rect[] {
const excludeSet = new Set(excludeIds)
return layerList.value
.filter(l => !excludeSet.has(l.id) && l.config?.visible !== false)
.map(l => ({ x: l.x, y: l.y, width: l.width, height: l.height }))
}
function getSnapshotLayers(ids: string[], map: Map<string, LayerRectSnapshot>) {
return ids
.map((id) => {
const snapshot = map.get(id)
if (!snapshot) {
return null
}
return {
id: snapshot.id,
type: 'custom',
x: snapshot.x,
y: snapshot.y,
width: snapshot.width,
height: snapshot.height,
} as Layer
})
.filter(Boolean) as Layer[]
}
function startDrag(ids: string[], pointer: { imageX: number, imageY: number }, groupId?: string | null) {
setEditorEmitter('propertyPanel:suspend')
dragState.active = true
dragState.ids = ids
dragState.groupId = groupId ?? null
dragState.startPointerX = pointer.imageX
dragState.startPointerY = pointer.imageY
dragState.startRects = createSnapshotMap(ids)
dragState.startGroupBounds = dragState.groupId
? (() => {
const group = getLayerGroupById(layerList.value, dragState.groupId || '')
if (!group) {
return null
}
return {
x: group.x,
y: group.y,
width: group.width,
height: group.height,
}
})()
: null
window.addEventListener('pointermove', onLayerPointerMove)
window.addEventListener('pointerup', onLayerPointerUp)
}
function onLayerPointerDown(payload: { event: PointerEvent, id: string }) {
const { event, id } = payload
if (event.button !== 0) {
return
}
if (!getCanvasElement() || canvasView.value.scale <= 0) {
return
}
const pointer = getPointerImagePosition(event)
const layer = layerList.value.find(entry => entry.id === id)
if (!layer || !pointer || isLocked(layer)) {
return
}
const additive = event.metaKey || event.ctrlKey || event.shiftKey
// 拖动多选中的任一图层时,沿用当前可拖动的选区一起移动。
const activeIds = (selectedLayerIds.value.includes(id) && selectedLayerIds.value.length > 1
? [...selectedLayerIds.value]
: [id]
).filter((entryId) => {
const entry = layerList.value.find(layer => layer.id === entryId)
return entry ? !isLocked(entry) : false
})
if (!activeIds.length) {
return
}
if (!additive && (!selectedLayerIds.value.includes(id) || selectedLayerIds.value.length <= 1)) {
layerStore.setSelectedLayerIds([id], id, { groupId: null })
}
if (!additive) {
selectedLayerId.value = id
}
startDrag(activeIds, pointer)
event.preventDefault()
}
listenEditorEmitter('canvasStage:layerPointerDown', onLayerPointerDown)
function onGroupPointerDown(payload: { event: PointerEvent, groupId: string }) {
const { event, groupId } = payload
if (event.button !== 0 || !getCanvasElement() || canvasView.value.scale <= 0) {
return
}
const pointer = getPointerImagePosition(event)
const group = getLayerGroupById(layerList.value, groupId)
if (!group || !pointer || group.layers.some(isLocked)) {
return
}
layerStore.setSelectedLayerIds(group.layerIds, group.layerIds[0] ?? '', { groupId })
startDrag(group.layerIds, pointer, groupId)
event.preventDefault()
}
listenEditorEmitter('canvasStage:groupPointerDown', onGroupPointerDown)
function onResizePointerDown(payload: { event: PointerEvent, id: string, handle: ResizeHandle }) {
const { event, id, handle } = payload
if (event.button !== 0) {
return
}
if (!canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
const pointer = getPointerImagePosition(event)
const layer = layerList.value.find(entry => entry.id === id)
if (!layer || !pointer || isLocked(layer)) {
return
}
event.preventDefault()
setEditorEmitter('propertyPanel:suspend')
layerStore.setSelectedLayerIds([id], id, { groupId: null })
selectedLayerId.value = id
resizeState.id = id
resizeState.groupId = null
resizeState.handle = handle
resizeState.startX = pointer.imageX
resizeState.startY = pointer.imageY
resizeState.startLeft = layer.x
resizeState.startTop = layer.y
resizeState.startRight = layer.x + layer.width
resizeState.startBottom = layer.y + layer.height
resizeState.startRects.clear()
window.addEventListener('pointermove', onResizePointerMove)
window.addEventListener('pointerup', onResizePointerUp)
}
listenEditorEmitter('canvasStage:layerResizePointerDown', onResizePointerDown)
function onGroupResizePointerDown(payload: { event: PointerEvent, groupId: string, handle: ResizeHandle }) {
const { event, groupId, handle } = payload
if (event.button !== 0 || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
const pointer = getPointerImagePosition(event)
const group = getLayerGroupById(layerList.value, groupId)
if (!group || !pointer || group.layers.some(isLocked)) {
return
}
event.preventDefault()
setEditorEmitter('propertyPanel:suspend')
layerStore.setSelectedLayerIds(group.layerIds, group.layerIds[0] ?? '', { groupId })
resizeState.id = null
resizeState.groupId = groupId
resizeState.handle = handle
resizeState.startX = pointer.imageX
resizeState.startY = pointer.imageY
resizeState.startLeft = group.x
resizeState.startTop = group.y
resizeState.startRight = group.x + group.width
resizeState.startBottom = group.y + group.height
resizeState.startRects = createSnapshotMap(group.layerIds)
window.addEventListener('pointermove', onResizePointerMove)
window.addEventListener('pointerup', onResizePointerUp)
}
listenEditorEmitter('canvasStage:groupResizePointerDown', onGroupResizePointerDown)
function onLayerPointerMove(event: PointerEvent) {
if (!dragState.active || !dragState.ids.length || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
const pointer = getPointerImagePosition(event)
if (!pointer) {
return
}
const deltaX = pointer.imageX - dragState.startPointerX
const deltaY = pointer.imageY - dragState.startPointerY
const canvasSize = { width: canvasView.value.imageWidth, height: canvasView.value.imageHeight }
const skipSnap = event.altKey
if (dragState.groupId && dragState.startGroupBounds) {
const bounds = dragState.startGroupBounds
const rawX = bounds.x + deltaX
const rawY = bounds.y + deltaY
let nextX: number
let nextY: number
if (skipSnap) {
nextX = rawX
nextY = rawY
clearGuides()
}
else {
const targetRect: Rect = { x: rawX, y: rawY, width: bounds.width, height: bounds.height }
const snap = calcSnap(targetRect, buildOtherRects(dragState.ids), canvasSize)
nextX = snap.snappedX
nextY = snap.snappedY
updateGuides({ guides: snap.guides, distances: snap.distances })
}
nextX = clamp(nextX, 0, Math.max(0, canvasSize.width - bounds.width))
nextY = clamp(nextY, 0, Math.max(0, canvasSize.height - bounds.height))
const updates = buildGroupPositionUpdates(
getSnapshotLayers(dragState.ids, dragState.startRects),
nextX - bounds.x,
nextY - bounds.y,
)
setEditorEmitter('layer:updatePositions', updates)
return
}
if (dragState.ids.length === 1) {
const id = dragState.ids[0]
const start = dragState.startRects.get(id)
if (!start) {
return
}
const rawX = start.x + deltaX
const rawY = start.y + deltaY
let nextX: number
let nextY: number
if (skipSnap) {
nextX = rawX
nextY = rawY
clearGuides()
}
else {
const targetRect: Rect = { x: rawX, y: rawY, width: start.width, height: start.height }
const snap = calcSnap(targetRect, buildOtherRects([id]), canvasSize)
nextX = snap.snappedX
nextY = snap.snappedY
updateGuides({ guides: snap.guides, distances: snap.distances })
}
nextX = clamp(nextX, 0, Math.max(0, canvasSize.width - start.width))
nextY = clamp(nextY, 0, Math.max(0, canvasSize.height - start.height))
setEditorEmitter('layer:updatePosition', { id, nextX, nextY })
return
}
// 多选拖拽用包围盒做吸附delta 统一应用
const allStarts = dragState.ids.map(id => dragState.startRects.get(id)).filter(Boolean) as LayerRectSnapshot[]
const bboxX = Math.min(...allStarts.map(s => s.x + deltaX))
const bboxY = Math.min(...allStarts.map(s => s.y + deltaY))
const bboxRight = Math.max(...allStarts.map(s => s.x + s.width + deltaX))
const bboxBottom = Math.max(...allStarts.map(s => s.y + s.height + deltaY))
const bboxRect: Rect = { x: bboxX, y: bboxY, width: bboxRight - bboxX, height: bboxBottom - bboxY }
let snapDeltaX = 0
let snapDeltaY = 0
if (skipSnap) {
clearGuides()
}
else {
const snap = calcSnap(bboxRect, buildOtherRects(dragState.ids), canvasSize)
snapDeltaX = snap.snappedX - bboxX
snapDeltaY = snap.snappedY - bboxY
updateGuides({ guides: snap.guides, distances: snap.distances })
}
const updates = dragState.ids
.map((id) => {
const start = dragState.startRects.get(id)
if (!start) {
return null
}
return {
id,
nextX: clamp(start.x + deltaX + snapDeltaX, 0, Math.max(0, canvasSize.width - start.width)),
nextY: clamp(start.y + deltaY + snapDeltaY, 0, Math.max(0, canvasSize.height - start.height)),
}
})
.filter(Boolean) as Array<{ id: string, nextX: number, nextY: number }>
setEditorEmitter('layer:updatePositions', updates)
}
function onLayerPointerUp() {
dragState.active = false
dragState.ids = []
dragState.groupId = null
dragState.startRects.clear()
dragState.startGroupBounds = null
clearGuides()
window.removeEventListener('pointermove', onLayerPointerMove)
window.removeEventListener('pointerup', onLayerPointerUp)
setEditorEmitter('propertyPanel:resume')
}
function onResizePointerMove(event: PointerEvent) {
if (!resizeState.handle || !canvasView.value.imageWidth || !canvasView.value.imageHeight) {
return
}
const pointer = getPointerImagePosition(event)
if (!pointer) {
return
}
const deltaX = pointer.imageX - resizeState.startX
const deltaY = pointer.imageY - resizeState.startY
const minSize = 24
const canvasSize = { width: canvasView.value.imageWidth, height: canvasView.value.imageHeight }
const skipSnap = event.altKey
let left = resizeState.startLeft
let right = resizeState.startRight
let top = resizeState.startTop
let bottom = resizeState.startBottom
// 先计算原始边缘位置
if (resizeState.handle.includes('w')) {
left = resizeState.startLeft + deltaX
}
if (resizeState.handle.includes('e')) {
right = resizeState.startRight + deltaX
}
if (resizeState.handle.includes('n')) {
top = resizeState.startTop + deltaY
}
if (resizeState.handle.includes('s')) {
bottom = resizeState.startBottom + deltaY
}
// 吸附
if (!skipSnap) {
const edges: ('left' | 'right' | 'top' | 'bottom')[] = []
if (resizeState.handle.includes('w'))
edges.push('left')
if (resizeState.handle.includes('e'))
edges.push('right')
if (resizeState.handle.includes('n'))
edges.push('top')
if (resizeState.handle.includes('s'))
edges.push('bottom')
const currentRect: Rect = { x: left, y: top, width: right - left, height: bottom - top }
const excludeId = resizeState.id ? [resizeState.id] : [...resizeState.startRects.keys()]
const snap = calcResizeSnap(edges, currentRect, buildOtherRects(excludeId), canvasSize)
if (snap.snappedEdges.left !== undefined)
left = snap.snappedEdges.left
if (snap.snappedEdges.right !== undefined)
right = snap.snappedEdges.right
if (snap.snappedEdges.top !== undefined)
top = snap.snappedEdges.top
if (snap.snappedEdges.bottom !== undefined)
bottom = snap.snappedEdges.bottom
updateGuides({ guides: snap.guides, distances: snap.distances })
}
else {
clearGuides()
}
// clamp 边界约束
if (resizeState.handle.includes('w'))
left = clamp(left, 0, resizeState.startRight - minSize)
if (resizeState.handle.includes('e'))
right = clamp(right, resizeState.startLeft + minSize, canvasSize.width)
if (resizeState.handle.includes('n'))
top = clamp(top, 0, resizeState.startBottom - minSize)
if (resizeState.handle.includes('s'))
bottom = clamp(bottom, resizeState.startTop + minSize, canvasSize.height)
const nextWidth = clamp(right - left, minSize, canvasSize.width)
const nextHeight = clamp(bottom - top, minSize, canvasSize.height)
const nextX = clamp(left, 0, Math.max(0, canvasSize.width - nextWidth))
const nextY = clamp(top, 0, Math.max(0, canvasSize.height - nextHeight))
if (resizeState.groupId) {
const updates = buildGroupRectUpdates(
getSnapshotLayers([...resizeState.startRects.keys()], resizeState.startRects),
{
x: resizeState.startLeft,
y: resizeState.startTop,
width: resizeState.startRight - resizeState.startLeft,
height: resizeState.startBottom - resizeState.startTop,
},
{ x: nextX, y: nextY, width: nextWidth, height: nextHeight },
)
setEditorEmitter('layer:updateRects', updates)
return
}
if (!resizeState.id) {
return
}
const layer = layerList.value.find(entry => entry.id === resizeState.id)
if (!layer || isLocked(layer)) {
return
}
setEditorEmitter('layer:updateRect', { id: layer.id, nextX, nextY, nextWidth, nextHeight })
}
function onResizePointerUp() {
resizeState.id = null
resizeState.groupId = null
resizeState.handle = null
resizeState.startRects.clear()
clearGuides()
window.removeEventListener('pointermove', onResizePointerMove)
window.removeEventListener('pointerup', onResizePointerUp)
setEditorEmitter('propertyPanel:resume')
}
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onLayerPointerMove)
window.removeEventListener('pointerup', onLayerPointerUp)
window.removeEventListener('pointermove', onResizePointerMove)
window.removeEventListener('pointerup', onResizePointerUp)
})
return {
snapGuides,
onLayerPointerDown,
onResizePointerDown,
}
}

View File

@@ -0,0 +1,420 @@
import { ref } from 'vue'
// ---- 类型定义 ----
export interface Rect {
x: number
y: number
width: number
height: number
}
export interface GuideLine {
type: 'edge' | 'center' | 'canvas'
direction: 'horizontal' | 'vertical'
position: number
start: number
end: number
}
export interface DistanceLabel {
direction: 'horizontal' | 'vertical'
x: number
y: number
distance: number
}
export interface SnapResult {
snappedX: number
snappedY: number
guides: GuideLine[]
distances: DistanceLabel[]
}
export interface ResizeSnapResult {
snappedEdges: Partial<Record<'left' | 'right' | 'top' | 'bottom', number>>
guides: GuideLine[]
distances: DistanceLabel[]
}
export interface SnapGuidesState {
guides: GuideLine[]
distances: DistanceLabel[]
}
const SNAP_THRESHOLD = 8
// ---- 内部工具 ----
interface SnapCandidate {
targetLine: number
refLine: number
distance: number
type: 'edge' | 'center' | 'canvas'
targetEdge: string
refRect: Rect
}
function extractVerticalLines(rect: Rect): { left: number, right: number, centerX: number } {
return {
left: rect.x,
right: rect.x + rect.width,
centerX: rect.x + rect.width / 2,
}
}
function extractHorizontalLines(rect: Rect): { top: number, bottom: number, centerY: number } {
return {
top: rect.y,
bottom: rect.y + rect.height,
centerY: rect.y + rect.height / 2,
}
}
function findBestSnap(
targetRect: Rect,
otherRects: Rect[],
canvasRect: Rect,
threshold: number,
) {
const allRefs: Array<{ rect: Rect, isCanvas: boolean }> = [
...otherRects.map(r => ({ rect: r, isCanvas: false })),
{ rect: canvasRect, isCanvas: true },
]
const vCandidates: SnapCandidate[] = []
const hCandidates: SnapCandidate[] = []
const targetV = extractVerticalLines(targetRect)
const targetH = extractHorizontalLines(targetRect)
for (const { rect: refRect, isCanvas } of allRefs) {
const refV = extractVerticalLines(refRect)
const refH = extractHorizontalLines(refRect)
const type = isCanvas ? 'canvas' as const : 'edge' as const
for (const [tKey, tVal] of Object.entries(targetV)) {
for (const [rKey, rVal] of Object.entries(refV)) {
const dist = Math.abs(tVal - rVal)
if (dist <= threshold) {
const rType = (rKey === 'centerX' || tKey === 'centerX') && !isCanvas ? 'center' as const : type
vCandidates.push({
targetLine: tVal,
refLine: rVal,
distance: dist,
type: rType,
targetEdge: tKey,
refRect,
})
}
}
}
for (const [tKey, tVal] of Object.entries(targetH)) {
for (const [rKey, rVal] of Object.entries(refH)) {
const dist = Math.abs(tVal - rVal)
if (dist <= threshold) {
const rType = (rKey === 'centerY' || tKey === 'centerY') && !isCanvas ? 'center' as const : type
hCandidates.push({
targetLine: tVal,
refLine: rVal,
distance: dist,
type: rType,
targetEdge: tKey,
refRect,
})
}
}
}
}
const bestV = vCandidates.length > 0
? vCandidates.reduce((best, c) => c.distance < best.distance ? c : best)
: null
const bestH = hCandidates.length > 0
? hCandidates.reduce((best, c) => c.distance < best.distance ? c : best)
: null
const matchedV = bestV ? vCandidates.filter(c => c.refLine === bestV.refLine) : []
const matchedH = bestH ? hCandidates.filter(c => c.refLine === bestH.refLine) : []
return { bestV, bestH, matchedV, matchedH }
}
function buildGuideLines(
matchedV: SnapCandidate[],
matchedH: SnapCandidate[],
snappedRect: Rect,
): GuideLine[] {
const guides: GuideLine[] = []
for (const m of matchedV) {
const start = Math.min(snappedRect.y, m.refRect.y)
const end = Math.max(snappedRect.y + snappedRect.height, m.refRect.y + m.refRect.height)
guides.push({
type: m.type,
direction: 'vertical',
position: m.refLine,
start,
end,
})
}
for (const m of matchedH) {
const start = Math.min(snappedRect.x, m.refRect.x)
const end = Math.max(snappedRect.x + snappedRect.width, m.refRect.x + m.refRect.width)
guides.push({
type: m.type,
direction: 'horizontal',
position: m.refLine,
start,
end,
})
}
// 去重(相同 direction + position 只保留范围最大的)
const deduped = new Map<string, GuideLine>()
for (const g of guides) {
const key = `${g.direction}-${g.position}`
const existing = deduped.get(key)
if (!existing) {
deduped.set(key, g)
}
else {
existing.start = Math.min(existing.start, g.start)
existing.end = Math.max(existing.end, g.end)
}
}
return [...deduped.values()]
}
// ---- 等间距吸附 ----
function hasOverlap(a: Rect, b: Rect, axis: 'horizontal' | 'vertical'): boolean {
if (axis === 'horizontal') {
return a.y < b.y + b.height && a.y + a.height > b.y
}
return a.x < b.x + b.width && a.x + a.width > b.x
}
function calcEqualSpacing(
targetRect: Rect,
otherRects: Rect[],
threshold: number,
): { deltaX: number, deltaY: number, distances: DistanceLabel[] } {
let deltaX = 0
let deltaY = 0
const distances: DistanceLabel[] = []
// 水平等间距
const hNeighbors = otherRects.filter(r => hasOverlap(r, targetRect, 'horizontal'))
const leftNeighbor = hNeighbors
.filter(r => r.x + r.width <= targetRect.x)
.sort((a, b) => (b.x + b.width) - (a.x + a.width))[0]
const rightNeighbor = hNeighbors
.filter(r => r.x >= targetRect.x + targetRect.width)
.sort((a, b) => a.x - b.x)[0]
if (leftNeighbor && rightNeighbor) {
const totalGap = rightNeighbor.x - (leftNeighbor.x + leftNeighbor.width) - targetRect.width
const equalX = leftNeighbor.x + leftNeighbor.width + totalGap / 2
if (Math.abs(targetRect.x - equalX) <= threshold) {
deltaX = equalX - targetRect.x
const leftGap = Math.round(equalX - (leftNeighbor.x + leftNeighbor.width))
const rightGap = Math.round(rightNeighbor.x - (equalX + targetRect.width))
const midY = targetRect.y + targetRect.height / 2
distances.push(
{ direction: 'horizontal', x: leftNeighbor.x + leftNeighbor.width + leftGap / 2, y: midY, distance: leftGap },
{ direction: 'horizontal', x: equalX + targetRect.width + rightGap / 2, y: midY, distance: rightGap },
)
}
}
// 垂直等间距
const vNeighbors = otherRects.filter(r => hasOverlap(r, targetRect, 'vertical'))
const topNeighbor = vNeighbors
.filter(r => r.y + r.height <= targetRect.y)
.sort((a, b) => (b.y + b.height) - (a.y + a.height))[0]
const bottomNeighbor = vNeighbors
.filter(r => r.y >= targetRect.y + targetRect.height)
.sort((a, b) => a.y - b.y)[0]
if (topNeighbor && bottomNeighbor) {
const totalGap = bottomNeighbor.y - (topNeighbor.y + topNeighbor.height) - targetRect.height
const equalY = topNeighbor.y + topNeighbor.height + totalGap / 2
if (Math.abs(targetRect.y - equalY) <= threshold) {
deltaY = equalY - targetRect.y
const topGap = Math.round(equalY - (topNeighbor.y + topNeighbor.height))
const bottomGap = Math.round(bottomNeighbor.y - (equalY + targetRect.height))
const midX = targetRect.x + targetRect.width / 2
distances.push(
{ direction: 'vertical', x: midX, y: topNeighbor.y + topNeighbor.height + topGap / 2, distance: topGap },
{ direction: 'vertical', x: midX, y: equalY + targetRect.height + bottomGap / 2, distance: bottomGap },
)
}
}
return { deltaX, deltaY, distances }
}
// ---- 公开 API ----
function snapToGridFallback(value: number, gridSize = 10, threshold = 4) {
if (gridSize <= 0) {
return value
}
const snapped = Math.round(value / gridSize) * gridSize
return Math.abs(snapped - value) <= threshold ? snapped : value
}
export function calcSnap(
targetRect: Rect,
otherRects: Rect[],
canvasSize: { width: number, height: number },
threshold = SNAP_THRESHOLD,
): SnapResult {
const canvasRect: Rect = { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height }
const { bestV, bestH, matchedV, matchedH } = findBestSnap(targetRect, otherRects, canvasRect, threshold)
let snappedX = targetRect.x
let snappedY = targetRect.y
let guides: GuideLine[] = []
let distances: DistanceLabel[] = []
const hasVSnap = bestV !== null
const hasHSnap = bestH !== null
if (hasVSnap && bestV) {
snappedX = targetRect.x + (bestV.refLine - bestV.targetLine)
}
if (hasHSnap && bestH) {
snappedY = targetRect.y + (bestH.refLine - bestH.targetLine)
}
const snappedRect: Rect = { x: snappedX, y: snappedY, width: targetRect.width, height: targetRect.height }
guides = buildGuideLines(matchedV, matchedH, snappedRect)
// 等间距吸附:仅在对应方向无边缘/中线匹配时检测
if (!hasVSnap || !hasHSnap) {
const eqRect: Rect = { x: snappedX, y: snappedY, width: targetRect.width, height: targetRect.height }
const eq = calcEqualSpacing(eqRect, otherRects, threshold)
if (!hasVSnap && eq.deltaX !== 0) {
snappedX += eq.deltaX
}
if (!hasHSnap && eq.deltaY !== 0) {
snappedY += eq.deltaY
}
distances = eq.distances
}
// Fallback 到网格吸附
// 水平等间距的 distances 标记为 'horizontal',影响 X 坐标;垂直同理
if (!hasVSnap && distances.filter(d => d.direction === 'horizontal').length === 0) {
snappedX = snapToGridFallback(snappedX)
}
if (!hasHSnap && distances.filter(d => d.direction === 'vertical').length === 0) {
snappedY = snapToGridFallback(snappedY)
}
return { snappedX, snappedY, guides, distances }
}
export function calcResizeSnap(
edges: ('left' | 'right' | 'top' | 'bottom')[],
currentRect: Rect,
otherRects: Rect[],
canvasSize: { width: number, height: number },
threshold = SNAP_THRESHOLD,
): ResizeSnapResult {
const canvasRect: Rect = { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height }
const allRefs: Array<{ rect: Rect, isCanvas: boolean }> = [
...otherRects.map(r => ({ rect: r, isCanvas: false })),
{ rect: canvasRect, isCanvas: true },
]
const snappedEdges: Partial<Record<'left' | 'right' | 'top' | 'bottom', number>> = {}
const guides: GuideLine[] = []
for (const edge of edges) {
let edgePos: number
if (edge === 'left') {
edgePos = currentRect.x
}
else if (edge === 'right') {
edgePos = currentRect.x + currentRect.width
}
else if (edge === 'top') {
edgePos = currentRect.y
}
else {
edgePos = currentRect.y + currentRect.height
}
const isVertical = edge === 'left' || edge === 'right'
let bestDist = threshold + 1
let bestRef: number | null = null
let bestType: GuideLine['type'] = 'edge'
let bestRefRect: Rect | null = null
for (const { rect: refRect, isCanvas } of allRefs) {
const refLines = isVertical
? [refRect.x, refRect.x + refRect.width, refRect.x + refRect.width / 2]
: [refRect.y, refRect.y + refRect.height, refRect.y + refRect.height / 2]
for (let i = 0; i < refLines.length; i++) {
const dist = Math.abs(edgePos - refLines[i])
if (dist < bestDist) {
bestDist = dist
bestRef = refLines[i]
bestType = isCanvas ? 'canvas' : (i === 2 ? 'center' : 'edge')
bestRefRect = refRect
}
}
}
if (bestRef !== null && bestRefRect) {
snappedEdges[edge] = bestRef
if (isVertical) {
guides.push({
type: bestType,
direction: 'vertical',
position: bestRef,
start: Math.min(currentRect.y, bestRefRect.y),
end: Math.max(currentRect.y + currentRect.height, bestRefRect.y + bestRefRect.height),
})
}
else {
guides.push({
type: bestType,
direction: 'horizontal',
position: bestRef,
start: Math.min(currentRect.x, bestRefRect.x),
end: Math.max(currentRect.x + currentRect.width, bestRefRect.x + bestRefRect.width),
})
}
}
}
return { snappedEdges, guides, distances: [] }
}
export function useSnapGuides() {
const snapGuides = ref<SnapGuidesState>({ guides: [], distances: [] })
function updateGuides(state: SnapGuidesState) {
snapGuides.value = state
}
function clearGuides() {
snapGuides.value = { guides: [], distances: [] }
}
return {
snapGuides,
updateGuides,
clearGuides,
calcSnap,
calcResizeSnap,
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More