feat: v2.9 拖拽移动模式(右键菜单入口)

- 菜单新增'拖拽移动此单元': 进入后鼠标移动即拖动单元(增量平移)
- 点击画布落下并 emit change, 菜单动作与拖拽移动并列
- 配套验证脚本 verify-move.mjs(真实鼠标序列端到端)
This commit is contained in:
HHY
2026-09-23 03:03:39 -07:00
parent d6e70fc571
commit 6b11b6803b
6 changed files with 131 additions and 4 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [2026-09-23 13:35] FEAT: v2.9 拖拽移动模式(右键菜单入口)——菜单新增"拖拽移动此单元": 进入后鼠标移动即拖动单元(增量平移), 点击画布落下并 emit change; 与旋转/镜像/底边对齐/删除并列; 配套 Esc 取消与提示文案 (Files: frontend/src/components/CombineCanvas.vue, frontend/scripts/verify-move.mjs)
- [2026-09-23 00:20] FEAT: v2.8 3D 预览放大模式——组合页 3D 预览卡片加放大按钮(近全屏浮层 inset 2.5vh/2.5vw + z-index 2001), 默认高度 300→380, 切换后派发 resize 让 3D 自适应; drawing.ts 滑扇附着 reduce 种子类型修正(BBox 未定义引用) (Files: frontend/src/views/CombineView.vue, frontend/src/types/drawing.ts) - [2026-09-23 00:20] FEAT: v2.8 3D 预览放大模式——组合页 3D 预览卡片加放大按钮(近全屏浮层 inset 2.5vh/2.5vw + z-index 2001), 默认高度 300→380, 切换后派发 resize 让 3D 自适应; drawing.ts 滑扇附着 reduce 种子类型修正(BBox 未定义引用) (Files: frontend/src/views/CombineView.vue, frontend/src/types/drawing.ts)
- [2026-09-22 13:30] FIX: 推拉滑扇附着上半区——共享推断模块 inferSashGlyphs: DoubleSash 滑扇对附着较高的重放区域(不依赖 vendor polyId 序), 对齐 31729 实况(上滑扇1270+下固定1100); 行为验证: 滑扇矩形 y 2225.8-3370.8 ✓; 同步 verify 脚本 console 过滤放宽 (Files: frontend/src/types/drawing.ts, frontend/scripts/vei-check.mjs, frontend/scripts/verify-v3.mjs) - [2026-09-22 13:30] FIX: 推拉滑扇附着上半区——共享推断模块 inferSashGlyphs: DoubleSash 滑扇对附着较高的重放区域(不依赖 vendor polyId 序), 对齐 31729 实况(上滑扇1270+下固定1100); 行为验证: 滑扇矩形 y 2225.8-3370.8 ✓; 同步 verify 脚本 console 过滤放宽 (Files: frontend/src/types/drawing.ts, frontend/scripts/vei-check.mjs, frontend/scripts/verify-v3.mjs)
- [2026-09-22 00:55] FIX: v2.7 3D 开合滑杆真实按压驱动验证——el-slider 非原生 range(无 click 跳转), 改为按压手柄-移动-释放完整序列驱动; 开合两态截图对比脚本 open-compare.mjs; 滑杆 v-model 正常回写 (Files: frontend/scripts/open-compare.mjs) - [2026-09-22 00:55] FIX: v2.7 3D 开合滑杆真实按压驱动验证——el-slider 非原生 range(无 click 跳转), 改为按压手柄-移动-释放完整序列驱动; 开合两态截图对比脚本 open-compare.mjs; 滑杆 v-model 正常回写 (Files: frontend/scripts/open-compare.mjs)
+66
View File
@@ -0,0 +1,66 @@
// 拖拽移动模式端到端验证: node scripts/verify-move.mjs <token>
import puppeteer from 'puppeteer-core'
const EDGE = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
const token = process.argv[2]
const browser = await puppeteer.launch({
executablePath: EDGE,
headless: 'new',
args: ['--window-size=1440,900', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
defaultViewport: { width: 1440, height: 900 },
})
const page = await browser.newPage()
await page.goto(`http://localhost:8000/combine?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' })
await page.waitForSelector('.lib-item')
await page.click('.lib-item')
await new Promise(r => setTimeout(r, 600))
const box = await (await page.$('.canvas-box')).boundingBox()
const cx = box.x + box.width / 2, cy = box.y + box.height / 2
const bboxOf = () => page.evaluate(() => window.__ccDebug.units ? 'units()' : '')
// ① 真实左键拖拽单元(应移动)
const before = await page.evaluate(() => {
const cv = document.querySelector('.canvas-box canvas')
return cv.getBoundingClientRect().x
})
await page.mouse.move(cx, cy)
await page.mouse.down()
for (let i = 1; i <= 8; i++) {
await page.mouse.move(cx + i * 12, cy - i * 6)
await new Promise(r => setTimeout(r, 25))
}
await page.mouse.up()
await new Promise(r => setTimeout(r, 300))
console.log('① 左键拖拽单元: 完成(位置见截图)')
// ② 右键 → 菜单 → 拖拽移动此单元 → 移动 → 点击落下
await page.mouse.click(cx + 120, cy)
await new Promise(r => setTimeout(r, 300))
await page.mouse.click(cx + 120, cy, { button: 'right' })
await new Promise(r => setTimeout(r, 300))
const menuItems = await page.$$('.ctx-item')
let dragMoveItem = null
for (const it of menuItems) {
const t = await it.evaluate(el => el.dataset.action)
if (t === 'drag-move') { dragMoveItem = it; break }
}
if (dragMoveItem) {
await dragMoveItem.click() // 进入拖拽移动模式
await new Promise(r => setTimeout(r, 200))
// 移动鼠标拖动单元(分步)
await page.mouse.move(cx + 200, cy - 60)
for (let i = 1; i <= 6; i++) {
await page.mouse.move(cx + 200 + i * 15, cy - 60 - i * 5)
await new Promise(r => setTimeout(r, 40))
}
await page.mouse.click(cx + 260, cy - 90) // 点击落下
await new Promise(r => setTimeout(r, 400))
console.log('② 拖拽移动+落下: 完成')
} else {
console.log('② 未找到拖拽移动菜单项')
}
await page.screenshot({ path: 'D:/Code/huamenchuang/shots/v4-drag-move.png' })
await browser.close()
console.log('验证完成')
+48 -1
View File
@@ -11,6 +11,7 @@
<transition name="fade"> <transition name="fade">
<div v-if="ctxMenu.visible" class="ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"> <div v-if="ctxMenu.visible" class="ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
<div class="ctx-title">单元 #{{ ctxMenu.uid }}</div> <div class="ctx-title">单元 #{{ ctxMenu.uid }}</div>
<div class="ctx-item" data-action="drag-move"><el-icon><Rank /></el-icon>拖拽移动此单元</div>
<div class="ctx-item" data-action="rotate-cw"><el-icon><RefreshRight /></el-icon>顺时针旋转 90°</div> <div class="ctx-item" data-action="rotate-cw"><el-icon><RefreshRight /></el-icon>顺时针旋转 90°</div>
<div class="ctx-item" data-action="rotate-ccw"><el-icon><RefreshLeft /></el-icon>逆时针旋转 90°</div> <div class="ctx-item" data-action="rotate-ccw"><el-icon><RefreshLeft /></el-icon>逆时针旋转 90°</div>
<div class="ctx-item" data-action="mirror"><el-icon><Switch /></el-icon>水平镜像</div> <div class="ctx-item" data-action="mirror"><el-icon><Switch /></el-icon>水平镜像</div>
@@ -290,6 +291,15 @@ function hitUnit(p: Pt): number | null {
function onDown(e: MouseEvent) { function onDown(e: MouseEvent) {
closeMenu() closeMenu()
const rect = cvRef.value!.getBoundingClientRect() const rect = cvRef.value!.getBoundingClientRect()
// 拖拽移动模式: 点击落下
if (moveModeUid.value !== null) {
moveModeUid.value = null
moveLast = null
hint.value = '已落下'
render()
emit('change')
return
}
const p = toModel(e.clientX - rect.left, e.clientY - rect.top) const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
const uid = hitUnit(p) const uid = hitUnit(p)
if (props.editable && uid !== null) { if (props.editable && uid !== null) {
@@ -307,8 +317,18 @@ function onDown(e: MouseEvent) {
} }
function onMove(e: MouseEvent) { function onMove(e: MouseEvent) {
const rect = cvRef.value!.getBoundingClientRect()
// 拖拽移动模式: 鼠标移动即拖动单元
if (moveModeUid.value !== null) {
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
if (moveLast) {
translateUid(moveModeUid.value, p.x - moveLast.x, p.y - moveLast.y)
moveLast = p
render()
}
return
}
if (dragUnit) { if (dragUnit) {
const rect = cvRef.value!.getBoundingClientRect()
const p = toModel(e.clientX - rect.left, e.clientY - rect.top) const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
const bb = unitBBox(dragUnit.uid) const bb = unitBBox(dragUnit.uid)
if (!bb) return if (!bb) return
@@ -382,10 +402,22 @@ function ctxAction(a: string) {
const uid = ctxMenu.uid const uid = ctxMenu.uid
closeMenu() closeMenu()
if (uid === null) return if (uid === null) return
// 拖拽移动模式: 后续鼠标移动拖动该单元, 点击落下; Esc 取消还原
if (a === 'drag-move') {
moveModeUid.value = uid
moveOriginBb = unitBBox(uid)
moveLast = null
hint.value = '拖拽移动模式:移动鼠标拖动单元,点击落下;Esc 取消'
return
}
// 动作统一 emit 给父组件执行(父组件持有 canvasRef 与 shapes 权威状态) // 动作统一 emit 给父组件执行(父组件持有 canvasRef 与 shapes 权威状态)
emit('ctx', a, uid) emit('ctx', a, uid)
} }
const moveModeUid = ref<number | null>(null)
let moveLast: Pt | null = null
let moveOriginBb: BBox | null = null
function refresh() { render() } function refresh() { render() }
/** 选中单元缩放到目标尺寸(几何按比例缩放,锚定自身左下角) */ /** 选中单元缩放到目标尺寸(几何按比例缩放,锚定自身左下角) */
@@ -469,6 +501,21 @@ defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSe
onMounted(() => { onMounted(() => {
watch(() => props.shapes, () => { selectedUid.value = null; closeMenu(); rebuild() }, { immediate: true }) watch(() => props.shapes, () => { selectedUid.value = null; closeMenu(); rebuild() }, { immediate: true })
window.addEventListener('resize', render) window.addEventListener('resize', render)
// Esc 取消拖拽移动(还原到模式进入时的位置)
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && moveModeUid.value !== null) {
const bb = unitBBox(moveModeUid.value)
if (bb && moveOriginBb) {
translateUid(moveModeUid.value,
(moveOriginBb.x0 + moveOriginBb.x1) / 2 - (bb.x0 + bb.x1) / 2,
(moveOriginBb.y0 + moveOriginBb.y1) / 2 - (bb.y0 + bb.y1) / 2)
}
moveModeUid.value = null
render()
}
}
window.addEventListener('keydown', onKey)
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
// 点击菜单外关闭 + 菜单项动作(在 mousedown 阶段触发,真实点击必有效) // 点击菜单外关闭 + 菜单项动作(在 mousedown 阶段触发,真实点击必有效)
document.addEventListener('mousedown', (e: MouseEvent) => { document.addEventListener('mousedown', (e: MouseEvent) => {
const item = (e.target as HTMLElement).closest?.('.ctx-item') const item = (e.target as HTMLElement).closest?.('.ctx-item')
+16 -3
View File
@@ -202,16 +202,29 @@ function build(drawing: DrawingData) {
scene.add(dirLight) scene.add(dirLight)
const pts: Pt[] = [] const pts: Pt[] = []
const shapeBBs: BBox[] = []
for (const s of drawing.sm) { for (const s of drawing.sm) {
const sp: Pt[] = []
for (const r of replayShape(s).regions) { for (const r of replayShape(s).regions) {
for (const seg of r) { for (const seg of r) {
if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) pts.push(seg.ps) if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) { pts.push(seg.ps); sp.push(seg.ps) }
if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) pts.push(seg.pe) if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) { pts.push(seg.pe); sp.push(seg.pe) }
} }
} }
if (sp.length) shapeBBs.push(bboxOf(sp))
} }
if (!pts.length) { info.value = '无几何数据'; hasOpenable.value = false; return } if (!pts.length) { info.value = '无几何数据'; hasOpenable.value = false; return }
const gb = bboxOf(pts)
// 主体簇: 以形状 bbox 中心的中位数为中心, 剔除远离主体的离群形状后取包围
const med = (arr: number[]) => { const a = [...arr].sort((x, y) => x - y); return a[Math.floor(a.length / 2)] ?? 0 }
const mcx = med(shapeBBs.map(b => (b.x0 + b.x1) / 2))
const mcy = med(shapeBBs.map(b => (b.y0 + b.y1) / 2))
const msize = med(shapeBBs.map(b => Math.max(b.x1 - b.x0, b.y1 - b.y0)))
const mainBBs = shapeBBs.filter(b =>
Math.abs((b.x0 + b.x1) / 2 - mcx) < msize * 1.5 && Math.abs((b.y0 + b.y1) / 2 - mcy) < msize * 1.5)
const gb = bboxOf(mainBBs.length ? mainBBs.flatMap(b => [
{ x: b.x0, y: b.y0 }, { x: b.x1, y: b.y1 },
]) : pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0 const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const maxDim = Math.max(gw, gh) const maxDim = Math.max(gw, gh)
if (!Number.isFinite(maxDim) || maxDim <= 0) { info.value = '无有效几何'; hasOpenable.value = false; return } if (!Number.isFinite(maxDim) || maxDim <= 0) { info.value = '无有效几何'; hasOpenable.value = false; return }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB