feat(map-editor): 站点/路径视口样式实时编辑与画布快捷键联动
- reflection API 新增 viewport-style GET/PATCH 及 ARGB 无符号化工具 - 属性面板「类型默认」重构为选中对象的视口样式实时编辑(防抖 + 序号防竞态) - 画布 iframe 聚焦时经 SSE 转发撤销/重做/删除,移除冗余的坐标添加菜单 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -243,6 +243,12 @@ async function del<T>(path: string): Promise<T> {
|
||||
return data.data as T
|
||||
}
|
||||
|
||||
async function patchJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const { data } = await http.patch<ReflectionEnvelope<T>>(`${BASE}${path}`, body)
|
||||
if (!data?.success) throw new Error(data?.message ?? `reflection PATCH ${path} failed`)
|
||||
return data.data as T
|
||||
}
|
||||
|
||||
export const reflectionApi = {
|
||||
listKinds: () => MOCK
|
||||
? Promise.resolve(mockReflectionKinds())
|
||||
@@ -500,6 +506,55 @@ export const reflectionApi = {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 车型样式(WorkspaceCarStylesByType / WorkspaceAlarmColorScheme)
|
||||
// /viewport-style GET 站点/路径视口 Painter 样式
|
||||
// /viewport-style PATCH 部分更新并立即重绘画布
|
||||
|
||||
getViewportStyle: () => MOCK
|
||||
? Promise.resolve({
|
||||
site: {
|
||||
drawScale: 2.5,
|
||||
dotRadiusNormal: 4,
|
||||
dotRadiusSelected: 6,
|
||||
selectionRingM: 0.14,
|
||||
colorNormalArgb: 0xffffffff,
|
||||
colorSelectedArgb: 0xffff0000,
|
||||
labelColorNormalArgb: 0xffffffff,
|
||||
labelColorSelectedArgb: 0xffff0000
|
||||
},
|
||||
track: {
|
||||
drawScale: 1.5,
|
||||
widthNormal: 1.2,
|
||||
widthSelected: 2.5,
|
||||
showDirectionArrows: true,
|
||||
colorNormalArgb: 0xff00ffff,
|
||||
colorSelectedArgb: 0xffff0000
|
||||
}
|
||||
} satisfies ViewportStylePayload)
|
||||
: get<ViewportStylePayload>('/viewport-style'),
|
||||
|
||||
patchViewportStyle: (patch: ViewportStylePatch) => MOCK
|
||||
? Promise.resolve({
|
||||
site: {
|
||||
drawScale: 2.5,
|
||||
dotRadiusNormal: 4,
|
||||
dotRadiusSelected: 6,
|
||||
selectionRingM: 0.14,
|
||||
colorNormalArgb: 0xffffffff,
|
||||
colorSelectedArgb: 0xffff0000,
|
||||
labelColorNormalArgb: 0xffffffff,
|
||||
labelColorSelectedArgb: 0xffff0000
|
||||
},
|
||||
track: {
|
||||
drawScale: 1.5,
|
||||
widthNormal: 1.2,
|
||||
widthSelected: 2.5,
|
||||
showDirectionArrows: true,
|
||||
colorNormalArgb: 0xff00ffff,
|
||||
colorSelectedArgb: 0xffff0000
|
||||
}
|
||||
} satisfies ViewportStylePayload)
|
||||
: patchJson<ViewportStylePayload>('/viewport-style', sanitizeViewportPatch(patch)),
|
||||
|
||||
// /car-style/types GET 列出所有 Car 子类型样式
|
||||
// /car-style/{typeFullName} GET 单个车型当前样式
|
||||
// /car-style/{typeFullName} POST body JSON 全量覆盖
|
||||
@@ -551,6 +606,117 @@ export const reflectionApi = {
|
||||
: post<{ path: string }>('/car-style/save')
|
||||
}
|
||||
|
||||
/** 与 SimpleLite 视口样式对话框 / Configuration.conf.viewport 同步。 */
|
||||
export interface ViewportStyleSite {
|
||||
drawScale: number
|
||||
dotRadiusNormal: number
|
||||
dotRadiusSelected: number
|
||||
selectionRingM: number
|
||||
colorNormalArgb: number
|
||||
colorSelectedArgb: number
|
||||
labelColorNormalArgb: number
|
||||
labelColorSelectedArgb: number
|
||||
}
|
||||
|
||||
export interface ViewportStyleTrack {
|
||||
drawScale: number
|
||||
widthNormal: number
|
||||
widthSelected: number
|
||||
showDirectionArrows: boolean
|
||||
colorNormalArgb: number
|
||||
colorSelectedArgb: number
|
||||
}
|
||||
|
||||
export interface ViewportStylePayload {
|
||||
site: ViewportStyleSite
|
||||
track: ViewportStyleTrack
|
||||
}
|
||||
|
||||
export type ViewportStylePatch = {
|
||||
site?: Partial<ViewportStyleSite>
|
||||
track?: Partial<ViewportStyleTrack>
|
||||
}
|
||||
|
||||
/** 将 ARGB 规范为无符号 32 位,避免 JSON 序列化成负数导致后端 UInt32 反序列化失败。 */
|
||||
export function normalizeArgb(argb: number): number {
|
||||
if (!Number.isFinite(argb)) return 0
|
||||
return argb >>> 0
|
||||
}
|
||||
|
||||
export function argbToHex(argb: number): string {
|
||||
const u = normalizeArgb(argb)
|
||||
const a = (u >>> 24) & 0xff
|
||||
const r = (u >>> 16) & 0xff
|
||||
const g = (u >>> 8) & 0xff
|
||||
const b = u & 0xff
|
||||
if (a >= 255) return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
return `#${a.toString(16).padStart(2, '0')}${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function hexToArgb(hex: string): number {
|
||||
const h = hex.replace('#', '').trim()
|
||||
if (h.length === 6) {
|
||||
const r = parseInt(h.slice(0, 2), 16)
|
||||
const g = parseInt(h.slice(2, 4), 16)
|
||||
const b = parseInt(h.slice(4, 6), 16)
|
||||
return normalizeArgb((0xff << 24) | (r << 16) | (g << 8) | b)
|
||||
}
|
||||
if (h.length === 8) {
|
||||
const a = parseInt(h.slice(0, 2), 16)
|
||||
const r = parseInt(h.slice(2, 4), 16)
|
||||
const g = parseInt(h.slice(4, 6), 16)
|
||||
const b = parseInt(h.slice(6, 8), 16)
|
||||
return normalizeArgb((a << 24) | (r << 16) | (g << 8) | b)
|
||||
}
|
||||
return 0xffffffff
|
||||
}
|
||||
|
||||
const VIEWPORT_SITE_ARGB_KEYS = [
|
||||
'colorNormalArgb',
|
||||
'colorSelectedArgb',
|
||||
'labelColorNormalArgb',
|
||||
'labelColorSelectedArgb'
|
||||
] as const
|
||||
|
||||
const VIEWPORT_TRACK_ARGB_KEYS = ['colorNormalArgb', 'colorSelectedArgb'] as const
|
||||
|
||||
/** PATCH 前把视口样式中的 ARGB 字段转为 UInt32 可接受的非负整数。 */
|
||||
export function sanitizeViewportPatch(patch: ViewportStylePatch): ViewportStylePatch {
|
||||
const out: ViewportStylePatch = {}
|
||||
if (patch.site) {
|
||||
const site = { ...patch.site }
|
||||
for (const k of VIEWPORT_SITE_ARGB_KEYS) {
|
||||
if (site[k] != null) site[k] = normalizeArgb(site[k]!)
|
||||
}
|
||||
out.site = site
|
||||
}
|
||||
if (patch.track) {
|
||||
const track = { ...patch.track }
|
||||
for (const k of VIEWPORT_TRACK_ARGB_KEYS) {
|
||||
if (track[k] != null) track[k] = normalizeArgb(track[k]!)
|
||||
}
|
||||
out.track = track
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function normalizeViewportPayload(raw: ViewportStylePayload): ViewportStylePayload {
|
||||
return {
|
||||
site: {
|
||||
...raw.site,
|
||||
colorNormalArgb: normalizeArgb(raw.site.colorNormalArgb),
|
||||
colorSelectedArgb: normalizeArgb(raw.site.colorSelectedArgb),
|
||||
labelColorNormalArgb: normalizeArgb(raw.site.labelColorNormalArgb),
|
||||
labelColorSelectedArgb: normalizeArgb(raw.site.labelColorSelectedArgb)
|
||||
},
|
||||
track: {
|
||||
...raw.track,
|
||||
colorNormalArgb: normalizeArgb(raw.track.colorNormalArgb),
|
||||
colorSelectedArgb: normalizeArgb(raw.track.colorSelectedArgb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CarStyleDto {
|
||||
bodyLengthM: number
|
||||
bodyWidthM: number
|
||||
|
||||
@@ -136,6 +136,11 @@ function onMessage(ev: MessageEvent) {
|
||||
} else if (msg.type === 'workspace.select' && Array.isArray(msg.payload)) {
|
||||
lastSelect.value = msg.payload as string[]
|
||||
emit('select', msg.payload as string[])
|
||||
} else if (msg.type === 'workspace.shortcut' && msg.payload && typeof msg.payload === 'object') {
|
||||
const action = (msg.payload as { action?: string }).action
|
||||
if (action === 'undo' || action === 'redo' || action === 'delete') {
|
||||
window.dispatchEvent(new CustomEvent('workspace-shortcut', { detail: { action } }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+229
-15
@@ -220,32 +220,181 @@
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="类型默认" name="defaults">
|
||||
<el-form label-width="120px" size="small" class="defaults-form">
|
||||
<el-alert type="info" :closable="false">
|
||||
为「新建站点 / 新建路径」预设默认样式与字段。保存后下次创建即生效。
|
||||
<div class="defaults-pane">
|
||||
<el-alert type="info" :closable="false" class="defaults-alert">
|
||||
视口样式与 SimpleLite「视口样式」对话框同步,修改后即时重绘画布;下方「新建默认」仅影响下次创建对象。
|
||||
</el-alert>
|
||||
<el-divider content-position="left">站点</el-divider>
|
||||
|
||||
<div v-if="!selectionKind" class="empty-hint defaults-empty">
|
||||
<el-icon class="empty-icon"><InfoFilled /></el-icon>
|
||||
<span class="empty-title">请选中站点或路径</span>
|
||||
<span class="empty-desc">在画布上选中一个站点或路径后,可在此调整对应视口样式。</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="selectionKind === 'site' && localViewportSite">
|
||||
<section class="prop-card prop-card--style">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">站点视口样式</span>
|
||||
<span class="prop-card-flag" :class="{ 'is-synced': viewportSynced }">
|
||||
<span class="prop-card-flag-dot" />
|
||||
<span class="prop-card-flag-text">{{ viewportSyncing ? '同步中…' : viewportSynced ? '已同步' : '实时同步' }}</span>
|
||||
</span>
|
||||
</header>
|
||||
<el-form label-width="132px" size="small" class="defaults-form">
|
||||
<el-form-item label="整体大小倍率">
|
||||
<el-input-number
|
||||
:model-value="localViewportSite.drawScale"
|
||||
:min="0.25"
|
||||
:max="12"
|
||||
:step="0.1"
|
||||
:controls="true"
|
||||
@update:model-value="(v: number | undefined) => patchSite({ drawScale: v ?? localViewportSite!.drawScale })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="未选中圆点半径">
|
||||
<el-input-number
|
||||
:model-value="localViewportSite.dotRadiusNormal"
|
||||
:min="1"
|
||||
:max="24"
|
||||
:step="0.5"
|
||||
@update:model-value="(v: number | undefined) => patchSite({ dotRadiusNormal: v ?? localViewportSite!.dotRadiusNormal })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="选中圆点半径">
|
||||
<el-input-number
|
||||
:model-value="localViewportSite.dotRadiusSelected"
|
||||
:min="1"
|
||||
:max="32"
|
||||
:step="0.5"
|
||||
@update:model-value="(v: number | undefined) => patchSite({ dotRadiusSelected: v ?? localViewportSite!.dotRadiusSelected })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="选中环半径 (m)">
|
||||
<el-input-number
|
||||
:model-value="localViewportSite.selectionRingM"
|
||||
:min="0.02"
|
||||
:max="0.8"
|
||||
:step="0.01"
|
||||
@update:model-value="(v: number | undefined) => patchSite({ selectionRingM: v ?? localViewportSite!.selectionRingM })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="站点颜色(未选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportSite.colorNormalArgb)"
|
||||
@update:model-value="(v: string | null) => patchSite({ colorNormalArgb: hexToArgb(v ?? '#ffffff') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="站点颜色(选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportSite.colorSelectedArgb)"
|
||||
@update:model-value="(v: string | null) => patchSite({ colorSelectedArgb: hexToArgb(v ?? '#ff0000') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签颜色(未选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportSite.labelColorNormalArgb)"
|
||||
@update:model-value="(v: string | null) => patchSite({ labelColorNormalArgb: hexToArgb(v ?? '#ffffff') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签颜色(选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportSite.labelColorSelectedArgb)"
|
||||
@update:model-value="(v: string | null) => patchSite({ labelColorSelectedArgb: hexToArgb(v ?? '#ff0000') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="prop-card">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">新建站点默认</span>
|
||||
</header>
|
||||
<el-form label-width="132px" size="small" class="defaults-form">
|
||||
<el-form-item label="默认图层">
|
||||
<el-input v-model="defaults.site.layer" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名字前缀">
|
||||
<el-input v-model="defaults.site.namePrefix" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认颜色">
|
||||
<el-color-picker v-model="defaults.site.color" show-alpha />
|
||||
<el-button size="small" type="primary" plain @click="onSaveDefaults">保存新建默认</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="selectionKind === 'track' && localViewportTrack">
|
||||
<section class="prop-card prop-card--style">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">路径视口样式</span>
|
||||
<span class="prop-card-flag" :class="{ 'is-synced': viewportSynced }">
|
||||
<span class="prop-card-flag-dot" />
|
||||
<span class="prop-card-flag-text">{{ viewportSyncing ? '同步中…' : viewportSynced ? '已同步' : '实时同步' }}</span>
|
||||
</span>
|
||||
</header>
|
||||
<el-form label-width="132px" size="small" class="defaults-form">
|
||||
<el-form-item label="线宽倍率">
|
||||
<el-input-number
|
||||
:model-value="localViewportTrack.drawScale"
|
||||
:min="0.25"
|
||||
:max="12"
|
||||
:step="0.1"
|
||||
@update:model-value="(v: number | undefined) => patchTrack({ drawScale: v ?? localViewportTrack!.drawScale })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">路径</el-divider>
|
||||
<el-form-item label="未选中线宽">
|
||||
<el-input-number
|
||||
:model-value="localViewportTrack.widthNormal"
|
||||
:min="0.4"
|
||||
:max="8"
|
||||
:step="0.1"
|
||||
@update:model-value="(v: number | undefined) => patchTrack({ widthNormal: v ?? localViewportTrack!.widthNormal })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="选中线宽">
|
||||
<el-input-number
|
||||
:model-value="localViewportTrack.widthSelected"
|
||||
:min="0.4"
|
||||
:max="12"
|
||||
:step="0.1"
|
||||
@update:model-value="(v: number | undefined) => patchTrack({ widthSelected: v ?? localViewportTrack!.widthSelected })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示方向箭头">
|
||||
<el-switch
|
||||
:model-value="localViewportTrack.showDirectionArrows"
|
||||
@update:model-value="(v: string | number | boolean) => patchTrack({ showDirectionArrows: Boolean(v) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="路径颜色(未选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportTrack.colorNormalArgb)"
|
||||
@update:model-value="(v: string | null) => patchTrack({ colorNormalArgb: hexToArgb(v ?? '#00ffff') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="路径颜色(选中)">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(localViewportTrack.colorSelectedArgb)"
|
||||
@update:model-value="(v: string | null) => patchTrack({ colorSelectedArgb: hexToArgb(v ?? '#ff0000') })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="prop-card">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">新建路径默认</span>
|
||||
</header>
|
||||
<el-form label-width="132px" size="small" class="defaults-form">
|
||||
<el-form-item label="默认图层">
|
||||
<el-input v-model="defaults.track.layer" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认线宽">
|
||||
<el-input-number v-model="defaults.track.lineWidth" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认颜色">
|
||||
<el-color-picker v-model="defaults.track.color" show-alpha />
|
||||
</el-form-item>
|
||||
<el-button size="small" type="primary" @click="onSaveDefaults">保存默认</el-button>
|
||||
<el-button size="small" type="primary" plain @click="onSaveDefaults">保存新建默认</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else-if="viewportStyleLoading" class="loading-hint">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载视口样式…</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="图层" name="layers">
|
||||
@@ -280,7 +429,15 @@
|
||||
<script setup lang="ts">
|
||||
import { InfoFilled, Loading } from '@element-plus/icons-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ReflectionMethod, ReflectionObject } from '@/api/reflection'
|
||||
import {
|
||||
argbToHex,
|
||||
hexToArgb,
|
||||
type ReflectionMethod,
|
||||
type ReflectionObject,
|
||||
type ViewportStyleSite,
|
||||
type ViewportStyleTrack,
|
||||
type ViewportStylePayload
|
||||
} from '@/api/reflection'
|
||||
|
||||
interface PropFieldKv { key: string; value: string }
|
||||
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
|
||||
@@ -298,6 +455,10 @@ const props = defineProps<{
|
||||
track: { layer: string; lineWidth: number; color: string }
|
||||
}
|
||||
layerRows: LayerRow[]
|
||||
selectionKind: 'site' | 'track' | null
|
||||
viewportStyle: ViewportStylePayload | null
|
||||
viewportStyleLoading?: boolean
|
||||
viewportSyncing?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -310,9 +471,58 @@ const emit = defineEmits<{
|
||||
(e: 'layer-toggle', row: LayerRow): void
|
||||
(e: 'add-layer', name: string): void
|
||||
(e: 'delete'): void
|
||||
(e: 'patch-viewport', patch: { site?: ViewportStyleSite; track?: ViewportStyleTrack }): void
|
||||
}>()
|
||||
|
||||
const tab = ref<'object' | 'defaults' | 'layers'>('object')
|
||||
const viewportSynced = ref(false)
|
||||
let viewportSyncedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const localViewportSite = ref<ViewportStyleSite | null>(null)
|
||||
const localViewportTrack = ref<ViewportStyleTrack | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.viewportStyle,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
localViewportSite.value = null
|
||||
localViewportTrack.value = null
|
||||
return
|
||||
}
|
||||
localViewportSite.value = { ...v.site }
|
||||
localViewportTrack.value = { ...v.track }
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectionKind,
|
||||
(k) => {
|
||||
if (k === 'site' || k === 'track') tab.value = 'defaults'
|
||||
}
|
||||
)
|
||||
|
||||
function flashViewportSynced() {
|
||||
viewportSynced.value = true
|
||||
if (viewportSyncedTimer) clearTimeout(viewportSyncedTimer)
|
||||
viewportSyncedTimer = setTimeout(() => { viewportSynced.value = false }, 1400)
|
||||
}
|
||||
|
||||
watch(() => props.viewportSyncing, (busy, wasBusy) => {
|
||||
if (wasBusy && !busy) flashViewportSynced()
|
||||
})
|
||||
|
||||
function patchSite(partial: Partial<ViewportStyleSite>) {
|
||||
if (!localViewportSite.value) return
|
||||
localViewportSite.value = { ...localViewportSite.value, ...partial }
|
||||
emit('patch-viewport', { site: { ...localViewportSite.value } })
|
||||
}
|
||||
|
||||
function patchTrack(partial: Partial<ViewportStyleTrack>) {
|
||||
if (!localViewportTrack.value) return
|
||||
localViewportTrack.value = { ...localViewportTrack.value, ...partial }
|
||||
emit('patch-viewport', { track: { ...localViewportTrack.value } })
|
||||
}
|
||||
|
||||
const actionBusy = ref<string | null>(null)
|
||||
const newLayerName = ref('')
|
||||
const deleting = ref(false)
|
||||
@@ -845,7 +1055,11 @@ function onDelete() {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.defaults-pane { display: flex; flex-direction: column; gap: 10px; }
|
||||
.defaults-alert { margin-bottom: 4px; }
|
||||
.defaults-empty { padding: 40px 12px; }
|
||||
.defaults-form .el-button { margin-top: 8px; }
|
||||
.defaults-form :deep(.el-form-item) { margin-bottom: 10px; }
|
||||
.layer-table { margin: 6px 0; }
|
||||
.new-layer { margin-top: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -42,25 +42,6 @@
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!--
|
||||
「添加」菜单:直接通过坐标 / 站点 ID 落地,不依赖画布 GetPoint。
|
||||
场景:iframe 模式下 SimpleUI.GetPoint 仅监听 LocalTerminal 时(或其他原因画布 pick 不响应),
|
||||
用户可走这条菜单先把站点 / 路径 / 模拟车造出来。
|
||||
-->
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">添加</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="add.site.byCoord">站点 (输入 x, y)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.polyline.byIds" divided>直线路径 (输入 siteA, siteB)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.bezier.byIds">贝塞尔路径 (siteA/B + 控制点坐标)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.nurbs.byIds">NURBS 路径 (siteA/B + 控制点坐标)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.arc.byIds">弧形路径 (siteA/B + 半径)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.car.dummy.byCoord" divided>模拟车 (输入 x, y, 朝向)</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">撤销</el-button>
|
||||
<template #dropdown>
|
||||
|
||||
@@ -49,6 +49,10 @@ export interface SelectionDetailEvent {
|
||||
names: string[]
|
||||
}
|
||||
|
||||
export interface WorkspaceShortcutEvent {
|
||||
action: 'undo' | 'redo' | 'delete'
|
||||
}
|
||||
|
||||
export interface MapEditStreamHandlers {
|
||||
onAlarm?: (e: AlarmEvent) => void
|
||||
onObjectCreated?: (e: ObjectChangeEvent) => void
|
||||
@@ -57,6 +61,10 @@ export interface MapEditStreamHandlers {
|
||||
onObjectBatchChanged?: (e: { count: number; source?: string }) => void
|
||||
onPickResult?: (e: PickResultEvent) => void
|
||||
onSelectionDetail?: (e: SelectionDetailEvent) => void
|
||||
/** iframe 画布聚焦时由 SimpleLite 经 SSE 转发的撤销/重做/删除。 */
|
||||
onWorkspaceShortcut?: (e: WorkspaceShortcutEvent) => void
|
||||
/** 视口样式(站点/路径 Painter)在 Web 或桌面对话框中变更后广播。 */
|
||||
onViewportStyleUpdated?: () => void
|
||||
}
|
||||
|
||||
export function useMapEditStream(handlers: MapEditStreamHandlers) {
|
||||
@@ -87,6 +95,12 @@ export function useMapEditStream(handlers: MapEditStreamHandlers) {
|
||||
case 'selection-detail':
|
||||
handlers.onSelectionDetail?.(payload as unknown as SelectionDetailEvent)
|
||||
break
|
||||
case 'workspace-shortcut':
|
||||
handlers.onWorkspaceShortcut?.(payload as unknown as WorkspaceShortcutEvent)
|
||||
break
|
||||
case 'viewport-style-updated':
|
||||
handlers.onViewportStyleUpdated?.()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@ export const KNOWN_EVENT_KINDS = [
|
||||
'project-loaded',
|
||||
'car-state',
|
||||
'mission-status',
|
||||
'monitor-config-updated'
|
||||
'monitor-config-updated',
|
||||
'workspace-shortcut',
|
||||
'viewport-style-updated'
|
||||
] as const
|
||||
|
||||
type Listener = (event: StreamEvent) => void
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
:layers="knownLayers"
|
||||
:defaults="defaults"
|
||||
:layer-rows="layerRows"
|
||||
:selection-kind="selectionKind"
|
||||
:viewport-style="viewportStyle"
|
||||
:viewport-style-loading="viewportStyleLoading"
|
||||
:viewport-syncing="viewportSyncing"
|
||||
@rename="onRename"
|
||||
@change-layer="onChangeLayer"
|
||||
@set-field="onSetField"
|
||||
@@ -49,6 +53,7 @@
|
||||
@layer-toggle="onLayerToggle"
|
||||
@add-layer="onAddLayer"
|
||||
@delete="deleteSelection"
|
||||
@patch-viewport="onPatchViewport"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +155,16 @@ import { useMapEditStream } from '@/composables/useMapEditStream'
|
||||
import { buildAlignOps, type AlignTarget, type AlignMode } from '@/composables/useAlignment'
|
||||
import { genLinearH, genLinearV, genMatrix, genCircular } from '@/composables/useBatchGenerate'
|
||||
import { mapEditApi, aiConfigApi } from '@/api/mapEdit'
|
||||
import { reflectionApi, type ReflectionMethod, type ReflectionObject, type ReflectionKind } from '@/api/reflection'
|
||||
import {
|
||||
reflectionApi,
|
||||
normalizeViewportPayload,
|
||||
type ReflectionMethod,
|
||||
type ReflectionObject,
|
||||
type ReflectionKind,
|
||||
type ViewportStylePayload,
|
||||
type ViewportStyleSite,
|
||||
type ViewportStyleTrack
|
||||
} from '@/api/reflection'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
|
||||
@@ -190,6 +204,22 @@ const defaults = reactive({
|
||||
track: { layer: 'g', lineWidth: 2, color: '#88ccff' }
|
||||
})
|
||||
|
||||
const viewportStyle = ref<ViewportStylePayload | null>(null)
|
||||
const viewportStyleLoading = ref(false)
|
||||
const viewportSyncing = ref(false)
|
||||
let viewportPatchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let viewportPatchSeq = 0
|
||||
let pendingViewportPatch: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {}
|
||||
|
||||
/** 单选且为 site/track 时驱动「类型默认」面板;多选或车型等返回 null。 */
|
||||
const selectionKind = computed<'site' | 'track' | null>(() => {
|
||||
const items = selection.items.value
|
||||
if (items.length !== 1) return null
|
||||
const k = items[0]!.kind
|
||||
if (k === 'site' || k === 'track') return k
|
||||
return null
|
||||
})
|
||||
|
||||
const knownLayers = ref<string[]>(['g'])
|
||||
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
|
||||
const layerRows = ref<LayerRow[]>([
|
||||
@@ -358,13 +388,6 @@ async function onTopBarCmd(cmd: string) {
|
||||
case 'view.fitSelection': ElMessage.info('请在 3D 视口右键菜单使用「适配选中」'); break
|
||||
case 'snap.toGrid': editTool.toggleSnap('grid'); break
|
||||
case 'snap.toObject': editTool.toggleSnap('object'); break
|
||||
// 「添加」菜单:用坐标 / ID 直接落地,不依赖画布 GetPoint。
|
||||
case 'add.site.byCoord': await promptAddSiteByCoord(); break
|
||||
case 'add.track.polyline.byIds': await promptAddTrackByIds('track'); break
|
||||
case 'add.track.bezier.byIds': await promptAddTrackByIds('bezier'); break
|
||||
case 'add.track.nurbs.byIds': await promptAddTrackByIds('nurbs'); break
|
||||
case 'add.track.arc.byIds': await promptAddTrackByIds('arc'); break
|
||||
case 'add.car.dummy.byCoord': await promptAddDummyCarByCoord(); break
|
||||
// 「图层」菜单:新建图层 / 切换图层显示
|
||||
case 'layer.new': await promptCreateLayer(); break
|
||||
case 'layer.toggleVisibility': layerVisibilityDialogOpen.value = true; break
|
||||
@@ -427,73 +450,6 @@ async function toggleViewFilter<G extends keyof ViewFilterState>(
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 「添加」菜单:坐标 / ID 直接落地(绕过画布 pick,覆盖 GetPoint 不响应等场景)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function promptAddSiteByCoord() {
|
||||
try {
|
||||
const x = Number((await ElMessageBox.prompt('X 坐标 (mm)', '添加站点', { inputValue: '0' })).value)
|
||||
const y = Number((await ElMessageBox.prompt('Y 坐标 (mm)', '添加站点', { inputValue: '0' })).value)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
ElMessage.warning('坐标必须是数字')
|
||||
return
|
||||
}
|
||||
await runCreateSite(x, y)
|
||||
} catch { /* user cancelled */ }
|
||||
}
|
||||
|
||||
async function promptAddTrackByIds(kind: 'track' | 'bezier' | 'arc' | 'nurbs') {
|
||||
const label = trackKindLabel(kind)
|
||||
try {
|
||||
const a = Math.floor(Number((await ElMessageBox.prompt('起点站 siteA (id)', `添加${label}`, { inputValue: '' })).value))
|
||||
const b = Math.floor(Number((await ElMessageBox.prompt('终点站 siteB (id)', `添加${label}`, { inputValue: '' })).value))
|
||||
if (!Number.isInteger(a) || !Number.isInteger(b) || a <= 0 || b <= 0) {
|
||||
ElMessage.warning('siteA / siteB 必须是正整数')
|
||||
return
|
||||
}
|
||||
if (a === b) { ElMessage.warning('起止站点不能相同'); return }
|
||||
drawTrackState.value.siteA = null
|
||||
|
||||
// 收集 typeInfo(贝塞尔 / NURBS:连续输入控制点坐标;弧线:输入半径自动算圆心)。
|
||||
// 失败 / 取消 → typeInfo = null,下面分两种情况:
|
||||
// - 用户主动取消 → 直接 return(不创建 track)
|
||||
// - 取不到 site 坐标兜底 → 仍然创建 track 但不带 typeInfo(与旧行为一致)
|
||||
let typeInfo: string | undefined
|
||||
let extraLabel = ''
|
||||
if (kind === 'bezier' || kind === 'nurbs') {
|
||||
const r = await collectCurveTypeInfoByCoord(a, b, kind)
|
||||
if (r === 'cancelled') return
|
||||
if (r != null) {
|
||||
typeInfo = r.typeInfo
|
||||
extraLabel = `(控制点 ${r.midCount})`
|
||||
}
|
||||
} else if (kind === 'arc') {
|
||||
const r = await collectArcTypeInfoByCoord(a, b)
|
||||
if (r === 'cancelled') return
|
||||
if (r != null) {
|
||||
typeInfo = r.typeInfo
|
||||
extraLabel = `(半径 ${r.radius.toFixed(0)})`
|
||||
}
|
||||
}
|
||||
|
||||
let lastId = -1
|
||||
await history.run({
|
||||
label: `新建${label} (${a}→${b})${extraLabel}`,
|
||||
apply: async () => {
|
||||
const payload: Record<string, unknown> = { siteA: a, siteB: b, layer: defaults.track.layer }
|
||||
if (typeInfo) payload.typeInfo = typeInfo
|
||||
const r = await mapEditApi.createTrack(kind, payload as { siteA: number; siteB: number })
|
||||
lastId = r.id
|
||||
},
|
||||
revert: async () => {
|
||||
if (lastId > 0) await mapEditApi.deleteObject('track', lastId)
|
||||
}
|
||||
})
|
||||
notifyTrackCreatedDefaults(kind)
|
||||
} catch { /* user cancelled */ }
|
||||
}
|
||||
|
||||
function trackKindLabel(kind: 'track' | 'bezier' | 'arc' | 'nurbs'): string {
|
||||
return kind === 'bezier' ? '贝塞尔路径'
|
||||
: kind === 'nurbs' ? 'NURBS 路径'
|
||||
@@ -502,68 +458,7 @@ function trackKindLabel(kind: 'track' | 'bezier' | 'arc' | 'nurbs'): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在「按 ID 添加」场景下收集贝塞尔 / NURBS 的中间控制点坐标,并拼出 typeInfo 字符串。
|
||||
*
|
||||
* 返回值:
|
||||
* - { typeInfo, midCount }:成功
|
||||
* - null:拉不到 site 坐标,调用方仍可创建 track 但不带 typeInfo
|
||||
* - 'cancelled':用户在某个对话框点了取消,调用方应直接 return(不创建 track)
|
||||
*/
|
||||
async function collectCurveTypeInfoByCoord(
|
||||
a: number,
|
||||
b: number,
|
||||
kind: 'bezier' | 'nurbs'
|
||||
): Promise<{ typeInfo: string; midCount: number } | null | 'cancelled'> {
|
||||
const sa = await fetchSiteXY(a)
|
||||
const sb = await fetchSiteXY(b)
|
||||
if (!sa || !sb) {
|
||||
ElMessage.warning(`无法拉到 site #${a} / #${b} 的 x,y,将不带 typeInfo 创建 track(控制点会用兜底直线)`)
|
||||
return null
|
||||
}
|
||||
const label = trackKindLabel(kind)
|
||||
let midCount: number
|
||||
try {
|
||||
const v = await ElMessageBox.prompt(
|
||||
'请输入中间控制点个数(不含起止站点;默认 2)',
|
||||
`添加${label}`,
|
||||
{ inputValue: '2', inputPattern: /^\d+$/, inputErrorMessage: '请输入非负整数' }
|
||||
)
|
||||
midCount = Math.max(0, Math.floor(Number(v.value)))
|
||||
} catch { return 'cancelled' }
|
||||
|
||||
const midPts: XYPoint[] = []
|
||||
for (let i = 0; i < midCount; i++) {
|
||||
let xv: number
|
||||
let yv: number
|
||||
try {
|
||||
const vx = await ElMessageBox.prompt(
|
||||
`第 ${i + 1}/${midCount} 个控制点 X (mm)`,
|
||||
`添加${label}`,
|
||||
{ inputValue: String(((sa.x + sb.x) / 2).toFixed(0)) }
|
||||
)
|
||||
xv = Number(vx.value)
|
||||
const vy = await ElMessageBox.prompt(
|
||||
`第 ${i + 1}/${midCount} 个控制点 Y (mm)`,
|
||||
`添加${label}`,
|
||||
{ inputValue: String(((sa.y + sb.y) / 2).toFixed(0)) }
|
||||
)
|
||||
yv = Number(vy.value)
|
||||
} catch { return 'cancelled' }
|
||||
if (!Number.isFinite(xv) || !Number.isFinite(yv)) {
|
||||
ElMessage.warning('控制点坐标必须是数字')
|
||||
return 'cancelled'
|
||||
}
|
||||
midPts.push({ x: xv, y: yv })
|
||||
}
|
||||
const allPts: XYPoint[] = [sa, ...midPts, sb]
|
||||
return {
|
||||
typeInfo: buildCurveTypeInfo(allPts, kind === 'bezier' ? 2 : 3),
|
||||
midCount
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「按 ID 添加」场景下收集弧线半径并拼出 typeInfo。
|
||||
* 画布绘制弧线时收集半径并拼出 typeInfo。
|
||||
*
|
||||
* 半径校验:必须 ≥ |AB|/2(小于则圆心不存在或重合,无法形成弧)。默认值用 ceil(|AB|/2) 即半圆。
|
||||
*/
|
||||
@@ -705,30 +600,6 @@ async function promptCreateLayer() {
|
||||
} catch { /* user cancelled */ }
|
||||
}
|
||||
|
||||
async function promptAddDummyCarByCoord() {
|
||||
try {
|
||||
const x = Number((await ElMessageBox.prompt('X 坐标 (mm)', '添加模拟车', { inputValue: '0' })).value)
|
||||
const y = Number((await ElMessageBox.prompt('Y 坐标 (mm)', '添加模拟车', { inputValue: '0' })).value)
|
||||
const theta = Number((await ElMessageBox.prompt('朝向 theta (rad,0 = +X 向右)', '添加模拟车', { inputValue: '0' })).value)
|
||||
const name = (await ElMessageBox.prompt('车名(可空)', '添加模拟车', { inputValue: '' })).value
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(theta)) {
|
||||
ElMessage.warning('坐标 / 朝向必须是数字')
|
||||
return
|
||||
}
|
||||
let createdId = -1
|
||||
await history.run({
|
||||
label: `新建模拟车 (${x.toFixed(0)}, ${y.toFixed(0)})`,
|
||||
apply: async () => {
|
||||
const r = await mapEditApi.createCar({ x, y, theta, name })
|
||||
createdId = r.id
|
||||
},
|
||||
revert: async () => {
|
||||
if (createdId > 0) await mapEditApi.deleteObject('car', createdId)
|
||||
}
|
||||
})
|
||||
} catch { /* user cancelled */ }
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 工具栏交互
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -1350,7 +1221,83 @@ async function onExecAction(method: string) {
|
||||
|
||||
function onSaveDefaults() {
|
||||
localStorage.setItem('mapEditor.defaults', JSON.stringify(defaults))
|
||||
ElMessage.success('类型默认已保存到本地')
|
||||
ElMessage.success('新建默认已保存(下次创建对象时生效)')
|
||||
}
|
||||
|
||||
function parseViewportPayload(raw: Record<string, unknown>): ViewportStylePayload {
|
||||
const siteRaw = (raw.site ?? raw.Site) as Record<string, unknown> | undefined
|
||||
const trackRaw = (raw.track ?? raw.Track) as Record<string, unknown> | undefined
|
||||
const num = (o: Record<string, unknown> | undefined, camel: string, pascal: string) =>
|
||||
Number(o?.[camel] ?? o?.[pascal] ?? 0)
|
||||
const bool = (o: Record<string, unknown> | undefined, camel: string, pascal: string) =>
|
||||
Boolean(o?.[camel] ?? o?.[pascal])
|
||||
return normalizeViewportPayload({
|
||||
site: {
|
||||
drawScale: num(siteRaw, 'drawScale', 'DrawScale'),
|
||||
dotRadiusNormal: num(siteRaw, 'dotRadiusNormal', 'DotRadiusNormal'),
|
||||
dotRadiusSelected: num(siteRaw, 'dotRadiusSelected', 'DotRadiusSelected'),
|
||||
selectionRingM: num(siteRaw, 'selectionRingM', 'SelectionRingM'),
|
||||
colorNormalArgb: num(siteRaw, 'colorNormalArgb', 'ColorNormalArgb'),
|
||||
colorSelectedArgb: num(siteRaw, 'colorSelectedArgb', 'ColorSelectedArgb'),
|
||||
labelColorNormalArgb: num(siteRaw, 'labelColorNormalArgb', 'LabelColorNormalArgb'),
|
||||
labelColorSelectedArgb: num(siteRaw, 'labelColorSelectedArgb', 'LabelColorSelectedArgb')
|
||||
},
|
||||
track: {
|
||||
drawScale: num(trackRaw, 'drawScale', 'DrawScale'),
|
||||
widthNormal: num(trackRaw, 'widthNormal', 'WidthNormal'),
|
||||
widthSelected: num(trackRaw, 'widthSelected', 'WidthSelected'),
|
||||
showDirectionArrows: bool(trackRaw, 'showDirectionArrows', 'ShowDirectionArrows'),
|
||||
colorNormalArgb: num(trackRaw, 'colorNormalArgb', 'ColorNormalArgb'),
|
||||
colorSelectedArgb: num(trackRaw, 'colorSelectedArgb', 'ColorSelectedArgb')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadViewportStyle() {
|
||||
viewportStyleLoading.value = true
|
||||
try {
|
||||
const raw = await reflectionApi.getViewportStyle()
|
||||
viewportStyle.value = parseViewportPayload(raw as unknown as Record<string, unknown>)
|
||||
} catch (err) {
|
||||
console.warn('[MapEditor] 加载视口样式失败:', err)
|
||||
} finally {
|
||||
viewportStyleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPatchViewport(patch: { site?: ViewportStyleSite; track?: ViewportStyleTrack }) {
|
||||
if (patch.site) {
|
||||
pendingViewportPatch.site = patch.site
|
||||
if (viewportStyle.value) viewportStyle.value = { ...viewportStyle.value, site: { ...patch.site } }
|
||||
}
|
||||
if (patch.track) {
|
||||
pendingViewportPatch.track = patch.track
|
||||
if (viewportStyle.value) viewportStyle.value = { ...viewportStyle.value, track: { ...patch.track } }
|
||||
}
|
||||
if (viewportPatchTimer) clearTimeout(viewportPatchTimer)
|
||||
viewportPatchTimer = setTimeout(() => {
|
||||
const body = { ...pendingViewportPatch }
|
||||
pendingViewportPatch = {}
|
||||
void flushViewportPatch(body)
|
||||
}, 350)
|
||||
}
|
||||
|
||||
async function flushViewportPatch(patch: { site?: ViewportStyleSite; track?: ViewportStyleTrack }) {
|
||||
const seq = ++viewportPatchSeq
|
||||
viewportSyncing.value = true
|
||||
try {
|
||||
const body: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {}
|
||||
if (patch.site) body.site = patch.site
|
||||
if (patch.track) body.track = patch.track
|
||||
const raw = await reflectionApi.patchViewportStyle(body)
|
||||
if (seq !== viewportPatchSeq) return
|
||||
viewportStyle.value = parseViewportPayload(raw as unknown as Record<string, unknown>)
|
||||
} catch (err) {
|
||||
ElMessage.error(`视口样式更新失败:${(err as Error).message}`)
|
||||
await loadViewportStyle()
|
||||
} finally {
|
||||
if (seq === viewportPatchSeq) viewportSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onLayerToggle(_row: LayerRow) {
|
||||
@@ -1515,11 +1462,13 @@ useCanvasBridge({
|
||||
})
|
||||
|
||||
const stream = useMapEditStream({
|
||||
onWorkspaceShortcut: (e) => { void handleEditorShortcut(e.action) },
|
||||
onAlarm: () => { /* 编辑器不展示报警,由 Monitor 页订阅 */ },
|
||||
onObjectCreated: () => refreshAfterMutation(),
|
||||
onObjectDeleted: () => refreshAfterMutation(),
|
||||
onObjectPatched: () => refreshAfterMutation(),
|
||||
onObjectBatchChanged: () => refreshAfterMutation(),
|
||||
onViewportStyleUpdated: () => { void loadViewportStyle() },
|
||||
onSelectionDetail: (e) => {
|
||||
// 把 SimpleLite 端 canvas 内的选中实时同步到 Vue 端,让右侧属性面板自动刷新。
|
||||
//
|
||||
@@ -1559,17 +1508,49 @@ watch(() => makeSelectionKey(selection.items.value), (key) => {
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 键盘快捷键
|
||||
// 键盘快捷键(顶栏/侧栏聚焦时走 document;画布 iframe 聚焦时走 SSE workspace-shortcut)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
const tag = target.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
||||
if (target.isContentEditable) return true
|
||||
return !!target.closest('.el-input, .el-textarea, .el-select, [contenteditable="true"]')
|
||||
}
|
||||
|
||||
async function handleEditorShortcut(action: 'undo' | 'redo' | 'delete') {
|
||||
if (action === 'undo') {
|
||||
if (!history.canUndo.value) return
|
||||
await history.undo()
|
||||
} else if (action === 'redo') {
|
||||
if (!history.canRedo.value) return
|
||||
await history.redo()
|
||||
} else {
|
||||
await deleteSelection()
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(ev: KeyboardEvent) {
|
||||
if (isEditableShortcutTarget(ev.target)) return
|
||||
|
||||
const meta = ev.ctrlKey || ev.metaKey
|
||||
if (meta && ev.key.toLowerCase() === 'z' && !ev.shiftKey) {
|
||||
ev.preventDefault(); history.undo()
|
||||
ev.preventDefault()
|
||||
void handleEditorShortcut('undo')
|
||||
} else if (meta && (ev.key.toLowerCase() === 'y' || (ev.key.toLowerCase() === 'z' && ev.shiftKey))) {
|
||||
ev.preventDefault(); history.redo()
|
||||
ev.preventDefault()
|
||||
void handleEditorShortcut('redo')
|
||||
} else if (ev.key === 'Delete') {
|
||||
deleteSelection()
|
||||
ev.preventDefault()
|
||||
void handleEditorShortcut('delete')
|
||||
}
|
||||
}
|
||||
|
||||
function onWorkspaceShortcutEvent(ev: Event) {
|
||||
const action = (ev as CustomEvent<{ action?: string }>).detail?.action
|
||||
if (action === 'undo' || action === 'redo' || action === 'delete') {
|
||||
void handleEditorShortcut(action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1592,7 +1573,8 @@ async function syncViewFilterFromBackend() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('workspace-shortcut', onWorkspaceShortcutEvent as EventListener)
|
||||
try {
|
||||
const cfg = await aiConfigApi.get()
|
||||
aiConfigured.value = !!cfg.configured
|
||||
@@ -1603,10 +1585,12 @@ onMounted(async () => {
|
||||
}
|
||||
loadViewFilter()
|
||||
await syncViewFilterFromBackend()
|
||||
await loadViewportStyle()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('workspace-shortcut', onWorkspaceShortcutEvent as EventListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user