Files
IOT_SIM/web/src/designer/FormDesigner.vue
T
xuyueandClaude Opus 5.5 ea5a25af06 窗体设计器:工具箱、拖放、属性与事件窗口,读写 VS 格式的 Designer.cs
- Designer.cs 在浏览器端解析为语句列表,未改动的语句保留原文:
  教材 25 个参考窗体和全部示例读入再写出逐字节不变
- 编辑操作:添加 / 删除 / 移动 / 改名控件、属性、事件、列表项;
  布局语句增删规则与 VS 一致(对全部现有文件执行无变化)
- 双击控件或事件:绑定事件并在窗体代码末尾插入处理方法,跳到代码
- 预览复用运行时的 ControlView,设计时与运行时外观一致
- 修正:窗体属性 this.ClientSize = new Size(...) 被误认成创建控件;
  示例 Designer.cs 的块头注释改为 VS 的 "// " 格式
- 测试:73 个(读写往返、布局规则、编辑操作、插入事件处理方法)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:11:48 +08:00

642 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 窗体设计器(仿 VS):左边工具箱,中间窗体,下方组件栏,右边属性 / 事件窗口。
// 窗体用运行时同一套 ControlView 渲染;上面盖一层透明的操作层,负责选中、拖动、改大小、放置新控件。
// 每次改动都重新生成 Designer.cs,通过 update 事件交给页面保存。
import { computed, ref, shallowRef, watch } from 'vue'
import ControlView from '../winforms/ControlView.vue'
import { displayText, fontCss, type FontValue } from '../winforms/format'
import { CONTROLS, type ControlDef, FORM_DEF, controlDef, isContainerType } from './catalog'
import { ensureHandler } from './code'
import { type DesignerDocument, DesignerParseError, generateDesigner, parseDesigner } from './document'
import {
addControl,
childrenOf,
getEventHandler,
getPropText,
listControls,
moveControl,
removeControl,
renameControl,
setEvent,
setItems,
setProp,
typeOf,
} from './edit'
import PropertyGrid from './PropertyGrid.vue'
import { componentsOf, previewTree } from './preview'
import { parseValue, type Value } from './values'
const props = defineProps<{
designerText: string
codeText: string
}>()
const emit = defineEmits<{
/** Designer.cs 改了;codeText 不为空时窗体代码文件也改了(插入了事件处理方法) */
update: [designerText: string, codeText?: string]
/** 跳到窗体代码文件的某一行 */
openCode: [line: number]
}>()
// ---------- 文档 ----------
const parsed = computed<{ doc?: DesignerDocument; error?: string }>(() => {
try {
return { doc: parseDesigner(props.designerText) }
} catch (error) {
return { error: error instanceof DesignerParseError ? error.message : String(error) }
}
})
/** 拖动过程中的临时文档(松手时才写回文件) */
const dragDoc = shallowRef<DesignerDocument>()
const doc = computed(() => dragDoc.value ?? parsed.value.doc)
const tree = computed(() => (doc.value ? previewTree(doc.value) : undefined))
const components = computed(() => (doc.value ? componentsOf(doc.value) : []))
/** 选中的控件;'' 表示窗体 */
const selected = ref('')
watch(parsed, () => {
if (selected.value && doc.value && !listControls(doc.value).some((c) => c.name === selected.value)) selected.value = ''
})
function commit(next: DesignerDocument, codeText?: string) {
dragDoc.value = undefined
emit('update', generateDesigner(next), codeText)
}
// ---------- 几何 ----------
interface Rect {
x: number
y: number
width: number
height: number
}
function valueOf(d: DesignerDocument, target: string, path: string): Value | undefined {
const text = getPropText(d, target, path)
return text === undefined ? undefined : parseValue(text)
}
function localRect(d: DesignerDocument, name: string): Rect {
const location = valueOf(d, name, 'Location')
const size = valueOf(d, name, 'Size')
return {
x: location?.kind === 'point' ? location.x : 0,
y: location?.kind === 'point' ? location.y : 0,
width: size?.kind === 'size' ? size.width : 75,
height: size?.kind === 'size' ? size.height : 23,
}
}
/** 控件在窗体客户区里的绝对位置(Location 相对于所在容器) */
function absRect(d: DesignerDocument, name: string): Rect {
const rect = localRect(d, name)
const parent = listControls(d).find((c) => c.name === name)?.parent
if (parent) {
const p = absRect(d, parent)
rect.x += p.x
rect.y += p.y
}
return rect
}
const clientSize = computed(() => {
const v = doc.value && valueOf(doc.value, '', 'ClientSize')
return v?.kind === 'size' ? v : { width: 300, height: 300 }
})
const contains = (r: Rect, x: number, y: number) => x >= r.x && y >= r.y && x < r.x + r.width && y < r.y + r.height
/** 找到点下的最上层控件(先查子控件;同一层里 Controls 中靠前的在上面) */
function hitTest(d: DesignerDocument, x: number, y: number, container = '', skip?: Set<string>): string | null {
for (const child of childrenOf(d, container)) {
if (skip?.has(child) || !contains(absRect(d, child), x, y)) continue
if (isContainerType(typeOf(d, child) ?? '')) return hitTest(d, x, y, child, skip) ?? child
return child
}
return null
}
/** 点所在的最深一层容器(窗体为 ''),拖放控件时用来决定放进哪个容器 */
function containerAt(d: DesignerDocument, x: number, y: number, skip?: Set<string>): string {
const hit = hitTest(d, x, y, '', skip)
if (hit === null) return ''
if (isContainerType(typeOf(d, hit) ?? '')) return hit
return listControls(d).find((c) => c.name === hit)?.parent ?? ''
}
function descendants(d: DesignerDocument, name: string): Set<string> {
const set = new Set([name])
for (const child of childrenOf(d, name)) for (const n of descendants(d, child)) set.add(n)
return set
}
const selectedRect = computed(() => (doc.value && selected.value ? absRect(doc.value, selected.value) : undefined))
const selectedAutoSize = computed(() => doc.value && selected.value && getPropText(doc.value, selected.value, 'AutoSize') === 'true')
// ---------- 指针操作:选中、移动、改大小 ----------
const surface = ref<HTMLElement>()
type Handle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
const HANDLES: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
let drag:
| { mode: 'move'; name: string; startX: number; startY: number; origin: Rect; moved: boolean }
| { mode: 'resize'; name: string; handle: Handle; startX: number; startY: number; origin: Rect }
| { mode: 'form'; startX: number; startY: number; origin: { width: number; height: number } }
| undefined
function pointer(event: PointerEvent | DragEvent) {
const box = surface.value!.getBoundingClientRect()
return { x: event.clientX - box.left, y: event.clientY - box.top }
}
function onPointerDown(event: PointerEvent) {
if (!doc.value || event.button !== 0) return
surface.value!.focus()
const { x, y } = pointer(event)
const hit = hitTest(doc.value, x, y)
selected.value = hit ?? ''
if (hit) {
drag = { mode: 'move', name: hit, startX: x, startY: y, origin: absRect(doc.value, hit), moved: false }
surface.value!.setPointerCapture(event.pointerId)
}
}
function startResize(event: PointerEvent, handle: Handle) {
if (!doc.value || !selected.value) return
const { x, y } = pointer(event)
drag = { mode: 'resize', name: selected.value, handle, startX: x, startY: y, origin: localRect(doc.value, selected.value) }
surface.value!.setPointerCapture(event.pointerId)
}
function startFormResize(event: PointerEvent) {
if (!doc.value) return
selected.value = ''
const { x, y } = pointer(event)
drag = { mode: 'form', startX: x, startY: y, origin: { ...clientSize.value } }
surface.value!.setPointerCapture(event.pointerId)
}
function onPointerMove(event: PointerEvent) {
const base = parsed.value.doc
if (!drag || !base) return
const { x, y } = pointer(event)
const dx = Math.round(x - drag.startX)
const dy = Math.round(y - drag.startY)
if (drag.mode === 'move') {
if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 3) return
drag.moved = true
const local = localRect(base, drag.name)
dragDoc.value = setProp(base, drag.name, 'Location', { kind: 'point', x: local.x + dx, y: local.y + dy })
} else if (drag.mode === 'resize') {
const r = { ...drag.origin }
const h = drag.handle
if (h.includes('e')) r.width = Math.max(4, drag.origin.width + dx)
if (h.includes('s')) r.height = Math.max(4, drag.origin.height + dy)
if (h.includes('w')) {
r.width = Math.max(4, drag.origin.width - dx)
r.x = drag.origin.x + drag.origin.width - r.width
}
if (h.includes('n')) {
r.height = Math.max(4, drag.origin.height - dy)
r.y = drag.origin.y + drag.origin.height - r.height
}
let next = setProp(base, drag.name, 'Location', { kind: 'point', x: r.x, y: r.y })
next = setProp(next, drag.name, 'Size', { kind: 'size', width: r.width, height: r.height })
dragDoc.value = next
} else {
dragDoc.value = setProp(base, '', 'ClientSize', {
kind: 'size',
width: Math.max(80, drag.origin.width + dx),
height: Math.max(40, drag.origin.height + dy),
})
}
}
function onPointerUp(event: PointerEvent) {
const base = parsed.value.doc
const current = drag
drag = undefined
if (!current || !base || !dragDoc.value) {
dragDoc.value = undefined
return
}
if (current.mode === 'move') {
// 松手的位置在另一个容器里:把控件移进去(坐标换算成相对新容器)
const { x, y } = pointer(event)
const target = containerAt(dragDoc.value, x, y, descendants(base, current.name))
const abs = absRect(dragDoc.value, current.name)
const origin = target ? absRect(dragDoc.value, target) : { x: 0, y: 0 }
commit(moveControl(base, current.name, target, { x: abs.x - origin.x, y: abs.y - origin.y }))
} else {
commit(dragDoc.value)
}
}
// ---------- 键盘 ----------
function onKeyDown(event: KeyboardEvent) {
const d = parsed.value.doc
if (!d || !selected.value) return
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
const name = selected.value
selected.value = ''
commit(removeControl(d, name))
return
}
const step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]
if (step && listControls(d).find((c) => c.name === selected.value)?.parent !== null) {
event.preventDefault()
const r = localRect(d, selected.value)
commit(setProp(d, selected.value, 'Location', { kind: 'point', x: r.x + step[0], y: r.y + step[1] }))
}
if (event.key === 'Escape') {
selected.value = listControls(d).find((c) => c.name === selected.value)?.parent ?? ''
}
}
// ---------- 工具箱 ----------
const TOOLBOX_GROUPS = ['常用控件', '容器', '组件', '物联网设备'] as const
const DRAG_TYPE = 'application/x-winforms-control'
function onToolDragStart(event: DragEvent, def: ControlDef) {
event.dataTransfer!.setData(DRAG_TYPE, def.type)
event.dataTransfer!.effectAllowed = 'copy'
}
function onDragOver(event: DragEvent) {
if (event.dataTransfer?.types.includes(DRAG_TYPE)) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}
function onDrop(event: DragEvent) {
const d = parsed.value.doc
const type = event.dataTransfer?.getData(DRAG_TYPE)
if (!d || !type) return
event.preventDefault()
const { x, y } = pointer(event)
place(d, controlDef(type), x, y)
}
/** 双击工具箱:放在选中的容器(或窗体)左上角 */
function onToolDoubleClick(def: ControlDef) {
const d = parsed.value.doc
if (!d) return
// 直接放进选中的容器,不按坐标判断(左上角可能正好压在另一个分组框上)
const container = !def.component && selected.value && isContainerType(typeOf(d, selected.value) ?? '') ? selected.value : ''
const result = addControl(d, def, container, { x: 12, y: container ? 20 : 12 })
selected.value = result.name
commit(result.doc)
}
function place(d: DesignerDocument, def: ControlDef, x: number, y: number) {
const container = def.component ? '' : containerAt(d, x, y)
const origin = container ? absRect(d, container) : { x: 0, y: 0 }
const result = addControl(d, def, container, { x: x - origin.x, y: y - origin.y })
selected.value = result.name
commit(result.doc)
}
// ---------- 属性窗口的回调 ----------
function onSetProp(target: string, path: string, value: Value | null) {
const d = parsed.value.doc
if (!d) return
let next = setProp(d, target, path, value)
// AutoSize 的控件:文字或字体变化时按文字重新计算大小(VS 也会这样更新 Size)
const def = target ? controlDef(typeOf(d, target) ?? '') : undefined
if (def?.autoSize && (path === 'Text' || path === 'AutoSize') && getPropText(next, target, 'AutoSize') === 'true') {
const text = valueOf(next, target, 'Text')
const size = def.autoSize(text?.kind === 'string' ? text.value : '')
next = setProp(next, target, 'Size', { kind: 'size', ...size })
}
commit(next)
}
function onSetItems(target: string, items: string[]) {
const d = parsed.value.doc
if (d) commit(setItems(d, target, items))
}
function onRename(from: string, to: string) {
const d = parsed.value.doc
if (!d) return
selected.value = to
commit(renameControl(d, from, to))
}
/** 绑定事件并在代码里生成处理方法(已存在则直接跳过去),然后跳到代码 */
function openEvent(target: string, eventName: string, handler?: string) {
const d = parsed.value.doc
if (!d) return
const def = target ? controlDef(typeOf(d, target) ?? '') : FORM_DEF
const event = def.events.find((e) => e.name === eventName)
if (!event) return
const name = handler ?? getEventHandler(d, target, eventName) ?? `${target || d.formName}_${eventName}`
const next = setEvent(d, target, eventName, event.delegate, name)
try {
const result = ensureHandler(props.codeText, d.className, name, event.args)
commit(next, result.code === props.codeText ? undefined : result.code)
emit('openCode', result.line)
} catch (error) {
commit(next)
alert(String(error))
}
}
function clearEvent(target: string, eventName: string) {
const d = parsed.value.doc
if (d) commit(setEvent(d, target, eventName, '', null))
}
/** 双击控件:生成默认事件(按钮的 Click、窗体的 Load……) */
function onDoubleClick(event: MouseEvent) {
const d = parsed.value.doc
if (!d) return
const box = surface.value!.getBoundingClientRect()
const hit = hitTest(d, event.clientX - box.left, event.clientY - box.top) ?? ''
const def = hit ? controlDef(typeOf(d, hit) ?? '') : FORM_DEF
openEvent(hit, def.defaultEvent)
}
function onComponentDoubleClick(name: string) {
const d = parsed.value.doc
if (d) openEvent(name, controlDef(typeOf(d, name) ?? '').defaultEvent)
}
const formTitle = computed(() => String(tree.value?.props.Text ?? ''))
const formStyle = computed(() => ({
width: `${clientSize.value.width}px`,
height: `${clientSize.value.height}px`,
color: (tree.value?.props.ForeColor as string | undefined) ?? undefined,
backgroundColor: (tree.value?.props.BackColor as string | undefined) ?? undefined,
...fontCss(tree.value?.props.Font as FontValue | undefined),
}))
const childrenTopLast = computed(() => [...(tree.value?.children ?? [])].reverse())
const noop = () => {}
</script>
<template>
<div v-if="parsed.error" class="cannot-open">
<p><b>设计器无法打开这个窗体。</b></p>
<p>{{ parsed.error }}</p>
<p>Designer.cs 的内容不是窗体设计器能识别的格式,可能是手动修改造成的。可以在代码里改回来,或者用"恢复原始代码"。</p>
</div>
<div v-else-if="doc && tree" class="designer">
<aside class="toolbox">
<div class="panel-title">工具箱</div>
<template v-for="group in TOOLBOX_GROUPS" :key="group">
<div class="group">{{ group }}</div>
<div
v-for="def in CONTROLS.filter((d) => d.group === group)"
:key="def.type"
class="tool"
draggable="true"
:title="`拖到窗体上,或双击添加`"
@dragstart="onToolDragStart($event, def)"
@dblclick="onToolDoubleClick(def)"
>
{{ def.label }}
</div>
</template>
</aside>
<section class="canvas">
<div class="window" :class="{ selected: selected === '' }">
<div class="titlebar" @pointerdown="selected = ''">
<span class="title">{{ displayText(formTitle) }}</span>
<span class="buttons">─ ☐ ✕</span>
</div>
<div class="client wf-client-host" :style="formStyle">
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="noop" />
<!-- 操作层:盖在窗体客户区上,接管所有鼠标操作 -->
<div
ref="surface"
class="surface"
tabindex="0"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@dblclick="onDoubleClick"
@keydown="onKeyDown"
@dragover="onDragOver"
@drop="onDrop"
>
<div
v-if="selectedRect"
class="selection"
:style="{ left: `${selectedRect.x}px`, top: `${selectedRect.y}px`, width: `${selectedRect.width}px`, height: `${selectedRect.height}px` }"
>
<template v-if="!selectedAutoSize">
<i v-for="h in HANDLES" :key="h" :class="['handle', h]" @pointerdown.stop="startResize($event, h)" />
</template>
</div>
</div>
<i class="form-resize" title="拖动改变窗体大小" @pointerdown.stop="startFormResize" />
</div>
</div>
<div class="tray">
<span class="tray-label">组件栏</span>
<span v-if="components.length === 0" class="tray-empty">把 Timer 等不显示在窗体上的组件拖到窗体上,会出现在这里</span>
<button
v-for="c in components"
:key="c.name"
class="component"
:class="{ active: selected === c.name }"
@click="selected = c.name"
@dblclick="onComponentDoubleClick(c.name)"
@keydown.delete="commit(removeControl(parsed.doc!, c.name)); selected = ''"
>
⏱ {{ c.name }}
</button>
</div>
</section>
<PropertyGrid
class="properties"
:doc="doc"
:selected="selected"
@set-prop="onSetProp"
@set-items="onSetItems"
@rename="onRename"
@open-event="openEvent"
@clear-event="clearEvent"
/>
</div>
</template>
<style scoped>
.designer {
display: grid;
grid-template-columns: 132px minmax(0, 1fr) 260px;
height: 100%;
min-height: 0;
border: 1px solid #e5e7eb;
}
.cannot-open {
padding: 24px;
color: #6b7280;
border: 1px solid #e5e7eb;
height: 100%;
box-sizing: border-box;
}
.toolbox {
overflow-y: auto;
border-right: 1px solid #e5e7eb;
background: #fafafa;
font-size: 12px;
}
.panel-title {
padding: 6px 8px;
font-weight: 600;
border-bottom: 1px solid #e5e7eb;
}
.group {
padding: 6px 8px 2px;
color: #6b7280;
font-size: 11px;
}
.tool {
padding: 4px 8px 4px 14px;
cursor: grab;
user-select: none;
}
.tool:hover {
background: #e8f0fe;
}
.canvas {
display: flex;
flex-direction: column;
min-width: 0;
overflow: auto;
background: #e9edf2;
padding: 16px;
gap: 12px;
}
.window {
align-self: flex-start;
border: 1px solid #9aa3ad;
background: #f0f0f0;
box-shadow: 0 2px 10px rgb(0 0 0 / 0.15);
}
.window.selected {
outline: 1px dashed #2563eb;
outline-offset: 2px;
}
.titlebar {
display: flex;
justify-content: space-between;
align-items: center;
height: 30px;
padding: 0 8px;
background: #fff;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 12px;
user-select: none;
}
.buttons {
color: #6b7280;
letter-spacing: 12px;
}
.client {
position: relative;
overflow: hidden;
font-family: "SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif;
font-size: 12px;
}
.surface {
position: absolute;
inset: 0;
outline: none;
cursor: default;
}
.selection {
position: absolute;
box-sizing: border-box;
border: 1px dashed #2563eb;
pointer-events: none;
}
.handle {
position: absolute;
width: 7px;
height: 7px;
background: #fff;
border: 1px solid #2563eb;
pointer-events: auto;
}
.handle.nw { left: -4px; top: -4px; cursor: nwse-resize; }
.handle.n { left: calc(50% - 4px); top: -4px; cursor: ns-resize; }
.handle.ne { right: -4px; top: -4px; cursor: nesw-resize; }
.handle.e { right: -4px; top: calc(50% - 4px); cursor: ew-resize; }
.handle.se { right: -4px; bottom: -4px; cursor: nwse-resize; }
.handle.s { left: calc(50% - 4px); bottom: -4px; cursor: ns-resize; }
.handle.sw { left: -4px; bottom: -4px; cursor: nesw-resize; }
.handle.w { left: -4px; top: calc(50% - 4px); cursor: ew-resize; }
.form-resize {
position: absolute;
right: 0;
bottom: 0;
width: 10px;
height: 10px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, #9aa3ad 50%);
}
.tray {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: #fff;
border: 1px solid #d5dbe2;
font-size: 12px;
}
.tray-label {
color: #6b7280;
}
.tray-empty {
color: #9ca3af;
}
.component {
padding: 2px 8px;
border: 1px solid #d1d5db;
border-radius: 3px;
background: #f9fafb;
cursor: pointer;
font: inherit;
}
.component.active {
border-color: #2563eb;
background: #e8f0fe;
}
.properties {
min-height: 0;
}
</style>