all
This commit is contained in:
32
packages/bridge/package.json
Normal file
32
packages/bridge/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
70
packages/bridge/src/adapters/electron.ts
Normal file
70
packages/bridge/src/adapters/electron.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
86
packages/bridge/src/adapters/tauri.ts
Normal file
86
packages/bridge/src/adapters/tauri.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
96
packages/bridge/src/adapters/web.ts
Normal file
96
packages/bridge/src/adapters/web.ts
Normal 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')
|
||||
},
|
||||
}
|
||||
}
|
||||
24
packages/bridge/src/index.ts
Normal file
24
packages/bridge/src/index.ts
Normal 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()
|
||||
1
packages/bridge/src/tauri.ts
Normal file
1
packages/bridge/src/tauri.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './adapters/tauri'
|
||||
70
packages/bridge/src/types.ts
Normal file
70
packages/bridge/src/types.ts
Normal 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
|
||||
}
|
||||
11
packages/bridge/tsconfig.json
Normal file
11
packages/bridge/tsconfig.json
Normal 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
4
packages/core/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# 默认环境变量配置(会被 .env.development 和 .env.production 覆盖)
|
||||
VITE_API_BASE_URL=https://cslab.oberyun.com
|
||||
|
||||
VITE_BASE_URL=/dcs-web/
|
||||
3
packages/core/.env.development
Normal file
3
packages/core/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://192.168.1.110:8001
|
||||
VITE_WS_DOMAIN=ws://192.168.1.110:6600
|
||||
2
packages/core/.env.production
Normal file
2
packages/core/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=https://cslab.oberyun.com
|
||||
22
packages/core/env.d.ts
vendored
Normal file
22
packages/core/env.d.ts
vendored
Normal 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 类型
|
||||
}
|
||||
50
packages/core/package.json
Normal file
50
packages/core/package.json
Normal 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
32
packages/core/src/App.vue
Normal 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>
|
||||
127
packages/core/src/api/canvas.ts
Normal file
127
packages/core/src/api/canvas.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
112
packages/core/src/api/dynamic-project.ts
Normal file
112
packages/core/src/api/dynamic-project.ts
Normal 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
|
||||
}
|
||||
4
packages/core/src/api/index.ts
Normal file
4
packages/core/src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './canvas'
|
||||
export * from './dynamic-project'
|
||||
export * from './project'
|
||||
export * from './runtime'
|
||||
15
packages/core/src/api/project.ts
Normal file
15
packages/core/src/api/project.ts
Normal 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()
|
||||
}
|
||||
99
packages/core/src/api/runtime.ts
Normal file
99
packages/core/src/api/runtime.ts
Normal 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 })
|
||||
}
|
||||
BIN
packages/core/src/assets/fonts/Oxanium.woff2
Normal file
BIN
packages/core/src/assets/fonts/Oxanium.woff2
Normal file
Binary file not shown.
6
packages/core/src/assets/fonts/index.css
Normal file
6
packages/core/src/assets/fonts/index.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'Oxanium';
|
||||
src: url('./Oxanium.woff2') format('woff2'),
|
||||
url('./Oxanium.woff') format('woff');
|
||||
font-weight: 400;
|
||||
}
|
||||
BIN
packages/core/src/assets/images/demo.png
Normal file
BIN
packages/core/src/assets/images/demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
BIN
packages/core/src/assets/images/demo1.png
Normal file
BIN
packages/core/src/assets/images/demo1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 708 KiB |
227
packages/core/src/assets/styles/element-plus-theme.scss
Normal file
227
packages/core/src/assets/styles/element-plus-theme.scss
Normal 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};
|
||||
}
|
||||
41
packages/core/src/assets/styles/index.scss
Normal file
41
packages/core/src/assets/styles/index.scss
Normal 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;
|
||||
}
|
||||
18
packages/core/src/assets/styles/variables.scss
Normal file
18
packages/core/src/assets/styles/variables.scss
Normal 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
11
packages/core/src/auto-imports.d.ts
vendored
Normal 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']
|
||||
}
|
||||
23
packages/core/src/bootstrap/bridge.ts
Normal file
23
packages/core/src/bootstrap/bridge.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
64
packages/core/src/bootstrap/editor.ts
Normal file
64
packages/core/src/bootstrap/editor.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
3
packages/core/src/bootstrap/index.ts
Normal file
3
packages/core/src/bootstrap/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './bridge'
|
||||
export * from './editor'
|
||||
export * from './route-state'
|
||||
52
packages/core/src/bootstrap/route-state.ts
Normal file
52
packages/core/src/bootstrap/route-state.ts
Normal 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
41
packages/core/src/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
543
packages/core/src/components/editor/canvas/composables/layer.ts
Normal file
543
packages/core/src/components/editor/canvas/composables/layer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
78
packages/core/src/components/editor/canvas/context/canvas.ts
Normal file
78
packages/core/src/components/editor/canvas/context/canvas.ts
Normal 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)!
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './canvas'
|
||||
export * from './editor-mode'
|
||||
export * from './page'
|
||||
export * from './runtime'
|
||||
export * from './state'
|
||||
export * from './ui'
|
||||
54
packages/core/src/components/editor/canvas/context/page.ts
Normal file
54
packages/core/src/components/editor/canvas/context/page.ts
Normal 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)!
|
||||
}
|
||||
372
packages/core/src/components/editor/canvas/context/runtime.ts
Normal file
372
packages/core/src/components/editor/canvas/context/runtime.ts
Normal 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)!
|
||||
}
|
||||
46
packages/core/src/components/editor/canvas/context/state.ts
Normal file
46
packages/core/src/components/editor/canvas/context/state.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
36
packages/core/src/components/editor/canvas/context/ui.ts
Normal file
36
packages/core/src/components/editor/canvas/context/ui.ts
Normal 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)!
|
||||
}
|
||||
92
packages/core/src/components/editor/canvas/emitter.ts
Normal file
92
packages/core/src/components/editor/canvas/emitter.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
221
packages/core/src/components/editor/canvas/grouping.ts
Normal file
221
packages/core/src/components/editor/canvas/grouping.ts
Normal 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
|
||||
}
|
||||
53
packages/core/src/components/editor/canvas/index.vue
Normal file
53
packages/core/src/components/editor/canvas/index.vue
Normal 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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './key-pointer'
|
||||
export * from './page'
|
||||
export * from './wheel'
|
||||
@@ -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)
|
||||
}
|
||||
10
packages/core/src/components/editor/canvas/listeners/page.ts
Normal file
10
packages/core/src/components/editor/canvas/listeners/page.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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 切换' },
|
||||
]
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ComponentType } from '@cslab-dcs/schema'
|
||||
|
||||
export interface ComponentPaletteItem {
|
||||
title: string
|
||||
type: ComponentType
|
||||
icon: string
|
||||
description?: string
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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: '模板' },
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
// 多选不同类型时使用公共 Section(geometry + 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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
1004
packages/core/src/components/editor/canvas/modules/stage/index.vue
Normal file
1004
packages/core/src/components/editor/canvas/modules/stage/index.vue
Normal file
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
Reference in New Issue
Block a user