窗体设计器:工具箱、拖放、属性与事件窗口,读写 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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 11:11:48 +08:00
1 parent 03e80ce2e2
commit ea5a25af06
20 files changed
+2767 -132

No files matched your search

+641
View File
@@ -0,0 +1,641 @@
<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>