优化whiteboard的箭头和直线功能

This commit is contained in:
jingrow 2026-05-03 16:17:34 +08:00
parent f051a077e0
commit 1c93d113da
6 changed files with 404 additions and 96 deletions

View File

@ -67,6 +67,7 @@ const {
redo,
saveHistory,
resize,
cancelDrawing,
stickyNoteColors,
} = useWhiteboard({
onElementDblClick: (elementId) => {
@ -126,6 +127,34 @@ const hasStrokeWidthControl = computed(() => {
//
const hasBorderRadiusControl = computed(() => selectedElement.value?.type === 'rectangle')
// /线
const isArrowElement = computed(() => {
const t = selectedElement.value?.type
return t === 'arrow' || t === 'line'
})
// 线
const hasArrowControl = computed(() => isArrowElement.value)
//
const currentRouteStyle = computed(() => {
const el = selectedElement.value as any
if (!el || !isArrowElement.value) return 'straight'
return el.connectionStyle?.route ?? 'straight'
})
const currentStartArrow = computed(() => {
const el = selectedElement.value as any
if (!el || !isArrowElement.value) return 'none'
return el.connectionStyle?.startArrow ?? 'none'
})
const currentEndArrow = computed(() => {
const el = selectedElement.value as any
if (!el || !isArrowElement.value) return 'arrow'
return el.connectionStyle?.endArrow ?? 'arrow'
})
//
const strokeWidthOptions = [
{ label: '1', value: 1 },
@ -268,9 +297,10 @@ function handleKeydown(e: KeyboardEvent): void {
setTool(toolShortcuts[e.key])
}
// Escape select
// Escape select / cancel drawing
if (e.key === 'Escape') {
setTool('select')
e.preventDefault()
cancelDrawing()
setSelectedIds([])
}
@ -522,6 +552,36 @@ function handleBorderRadiusChange(value: number): void {
ids.forEach(id => updateElementStyle(id, { borderRadius: value }))
}
/** 更新连线路由样式 */
function handleRouteStyleChange(value: 'straight' | 'orthogonal' | 'bezier'): void {
const ids = getSelectedIds()
ids.forEach(id => {
const el = board.value.getElement(id) as any
if (!el || !el.connectionStyle) return
updateElement(id, { connectionStyle: { ...el.connectionStyle, route: value } } as any)
})
}
/** 更新起点箭头样式 */
function handleStartArrowChange(value: 'none' | 'arrow' | 'circle' | 'diamond'): void {
const ids = getSelectedIds()
ids.forEach(id => {
const el = board.value.getElement(id) as any
if (!el || !el.connectionStyle) return
updateElement(id, { connectionStyle: { ...el.connectionStyle, startArrow: value } } as any)
})
}
/** 更新终点箭头样式 */
function handleEndArrowChange(value: 'none' | 'arrow' | 'circle' | 'diamond'): void {
const ids = getSelectedIds()
ids.forEach(id => {
const el = board.value.getElement(id) as any
if (!el || !el.connectionStyle) return
updateElement(id, { connectionStyle: { ...el.connectionStyle, endArrow: value } } as any)
})
}
/** 更新 X 坐标 */
function handleXChange(value: number | null): void {
if (value === null) return
@ -911,6 +971,59 @@ function handleResize(): void {
</div>
</template>
<!-- ========== 箭头控制 ========== -->
<template v-if="hasArrowControl">
<div class="panel-section">
<!-- 连线样式路由 -->
<div class="panel-section-title">{{ t('Route Style') }}</div>
<div class="prop-row">
<n-select
size="tiny"
:value="currentRouteStyle"
@update:value="handleRouteStyleChange"
:options="[
{ label: 'Straight', value: 'straight' },
{ label: 'Orthogonal', value: 'orthogonal' },
{ label: 'Bezier', value: 'bezier' },
]"
style="width: 100%;"
/>
</div>
<!-- 起点箭头 -->
<div class="panel-section-title" style="margin-top: 10px;">{{ t('Start Arrow') }}</div>
<div class="prop-row">
<n-select
size="tiny"
:value="currentStartArrow"
@update:value="handleStartArrowChange"
:options="[
{ label: 'None', value: 'none' },
{ label: 'Arrow', value: 'arrow' },
{ label: 'Circle', value: 'circle' },
{ label: 'Diamond', value: 'diamond' },
]"
style="width: 100%;"
/>
</div>
<!-- 终点箭头 -->
<div class="panel-section-title" style="margin-top: 10px;">{{ t('End Arrow') }}</div>
<div class="prop-row">
<n-select
size="tiny"
:value="currentEndArrow"
@update:value="handleEndArrowChange"
:options="[
{ label: 'None', value: 'none' },
{ label: 'Arrow', value: 'arrow' },
{ label: 'Circle', value: 'circle' },
{ label: 'Diamond', value: 'diamond' },
]"
style="width: 100%;"
/>
</div>
</div>
</template>
<!-- ========== 透明度 ========== -->
<div class="panel-section">
<div class="panel-section-title">{{ t('Opacity') }}</div>

View File

@ -395,11 +395,27 @@ export class BoardModel {
/** 更新元素位置 */
updateElementPosition(id: string, x: number, y: number): void {
const el = this._elements.get(id)
if (el) {
(el as any).x = x
;(el as any).y = y
this._notifyChange()
if (!el) return
const dx = x - el.x
const dy = y - el.y
;(el as any).x = x
;(el as any).y = y
// 同步箭头/线条的端点坐标
if ((el.type === 'arrow' || el.type === 'line') && ('startPoint' in el)) {
;(el as any).startPoint = {
x: (el as any).startPoint.x + dx,
y: (el as any).startPoint.y + dy,
}
;(el as any).endPoint = {
x: (el as any).endPoint.x + dx,
y: (el as any).endPoint.y + dy,
}
}
this._notifyChange()
}
/** 更新元素尺寸 */

View File

@ -100,13 +100,13 @@ export interface ConnectionStyle extends ElementStyle {
/** 默认连线样式 */
export const DEFAULT_CONNECTION_STYLE: ConnectionStyle = {
stroke: '#64748b',
stroke: '#3b82f6',
strokeWidth: 2,
color: '#1e293b',
fontSize: 12,
startArrow: 'none',
endArrow: 'arrow',
route: 'orthogonal',
route: 'straight',
}
// ===== 元素数据 =====
@ -312,8 +312,8 @@ export interface BoardInteractionEvent {
tool?: ToolType
}
/** Resize 手柄方位 */
export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
/** Resize 手柄方位nw/n/ne/e/se/s/sw/w 为包围盒角点start/end 为线段端点drag 为线段中点平移) */
export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'start' | 'end' | 'drag'
/** 选择状态 */
export interface BoardSelectionState {

View File

@ -42,6 +42,7 @@ interface InteractionState {
// Resize 相关
resizeHandle: ResizeHandle | null
resizeStartElement: { x: number; y: number; width: number; height: number } | null
resizeStartPoints: { startPoint: Point; endPoint: Point } | null // 线段端点拖拽时存储原始端点
// 绘制相关
drawingStartElement: Point | null
// 自由画笔
@ -73,6 +74,7 @@ export class BoardInteractionManager {
hasDuplicatedOnAltDrag: false,
resizeHandle: null,
resizeStartElement: null,
resizeStartPoints: null,
drawingStartElement: null,
freehandPoints: [],
wasJustDrawing: false,
@ -277,6 +279,7 @@ export class BoardInteractionManager {
this.state.hasDuplicatedOnAltDrag = false
this.state.resizeHandle = null
this.state.resizeStartElement = null
this.state.resizeStartPoints = null
this.state.drawingStartElement = null
this.state.freehandPoints = []
this.state.wasJustDrawing = false
@ -413,18 +416,42 @@ export class BoardInteractionManager {
/** 选择工具 - 按下 */
private handleSelectDown(worldPos: Point, e: MouseEvent): void {
// 优先检测选中元素的 resize 手柄(手柄命中区域可能在元素边界外)
// 正在绘制形状中(鼠标按下后正在拖动创建)→ 不触发选择
if (this.state.isDrawing) return
// 优先检测选中元素的 resize 手柄
for (const id of this.selectedIds) {
const selEl = this.board.getElement(id)
if (!selEl) continue
const handle = this.renderer.hitTestResizeHandle(selEl, worldPos)
if (handle) {
this.state.isResizing = true
this.state.resizeHandle = handle
const hit = this.renderer.hitTestResizeHandle(selEl, worldPos)
if (!hit) continue
// 点击在线条本身上(非手柄区域)→ 整条拖拽
if (hit === 'drag') {
this.state.isDragging = true
this.state.draggedElementId = id
this.state.resizeStartElement = { x: selEl.x, y: selEl.y, width: selEl.width, height: selEl.height }
this.state.dragStartPositions.clear()
if (selEl.type === 'arrow' || selEl.type === 'line') {
this.state.dragStartPositions.set(id, { x: (selEl as any).startPoint?.x ?? selEl.x, y: (selEl as any).startPoint?.y ?? selEl.y })
} else {
this.state.dragStartPositions.set(id, { x: selEl.x, y: selEl.y })
}
this.updateRendererSelection()
return
}
// 点击在 resize 手柄上 → resize
this.state.isResizing = true
this.state.resizeHandle = hit
this.state.draggedElementId = id
this.state.resizeStartElement = { x: selEl.x, y: selEl.y, width: selEl.width, height: selEl.height }
if ((selEl.type === 'arrow' || selEl.type === 'line') && 'startPoint' in selEl) {
this.state.resizeStartPoints = {
startPoint: { ...(selEl as any).startPoint },
endPoint: { ...(selEl as any).endPoint },
}
}
return
}
const el = this.board.getElementAtPoint(worldPos, this.board.activePageId)
@ -521,29 +548,8 @@ export class BoardInteractionManager {
}
this.state.dragStartPositions.forEach((startPos, id) => {
// updateElementPosition 内部已同步 arrow/line 的 startPoint/endPoint无需重复处理
this.board.updateElementPosition(id, startPos.x + dx, startPos.y + dy)
// 同步移动箭头/线段的端点
const el = this.board.getElement(id)
if (el && (el.type === 'arrow' || el.type === 'line')) {
const arrow = el as any
if (arrow.startPoint) {
// 起始位置是相对元素 (0,0) 的,只需保持相对坐标不变
// updateElementPosition 已移动了 el.x/y端点的世界坐标 = new el.x + relativeOffset
// 但 startPoint 在 ArrowElement 里是世界坐标,需要同步偏移 dx/dy
const origStart = this.state.dragStartPositions.get(id)
if (origStart) {
arrow.startPoint = {
x: arrow.startPoint.x + dx,
y: arrow.startPoint.y + dy,
}
arrow.endPoint = {
x: arrow.endPoint.x + dx,
y: arrow.endPoint.y + dy,
}
}
}
}
})
this.renderer.forceRedraw()
@ -566,6 +572,29 @@ export class BoardInteractionManager {
const el = this.board.getElement(this.state.draggedElementId)
if (!el) return
// 线段端点拖拽arrow/line
if ((el.type === 'arrow' || el.type === 'line') && this.state.resizeStartPoints) {
const sp = this.state.resizeStartPoints.startPoint
const ep = this.state.resizeStartPoints.endPoint
if (handle === 'start') {
;(el as any).startPoint = { x: sp.x + dx, y: sp.y + dy }
} else if (handle === 'end') {
;(el as any).endPoint = { x: ep.x + dx, y: ep.y + dy }
} else if (handle === 'n') {
// 中点拖拽 → 平移整条线
const origDx = worldPos.x - (sp.x + ep.x) / 2
const origDy = worldPos.y - (sp.y + ep.y) / 2
;(el as any).startPoint = { x: sp.x + origDx, y: sp.y + origDy }
;(el as any).endPoint = { x: ep.x + origDx, y: ep.y + origDy }
}
this.renderer.forceRedraw()
this.emit('element:resize:move', { elementId: el.id, position: worldPos })
return
}
// 包围盒 resize普通形状
let newX = start.x, newY = start.y, newW = start.width, newH = start.height
// 根据 handle 方向计算新的位置和尺寸
@ -605,12 +634,21 @@ export class BoardInteractionManager {
const type = shapeTypeMap[this.currentTool]
if (type) {
// 初始化起点位置(所有形状都用此基准)
const startX = worldPos.x
const startY = worldPos.y
const el = this.board.addElement(type, {
x: worldPos.x,
y: worldPos.y,
x: startX,
y: startY,
width: 1,
height: 1,
// 箭头/线条:用 startPoint/endPoint 管理端点x/y/width/height 保持与 startPoint 一致
...(type === 'arrow' || type === 'line'
? { startPoint: { x: startX, y: startY }, endPoint: { x: startX, y: startY } }
: {}),
})
this.state.draggedElementId = el.id
this.selectedIds.clear()
this.selectedIds.add(el.id)
@ -626,19 +664,20 @@ export class BoardInteractionManager {
if (!el) return
if (el.type === 'arrow' || el.type === 'line') {
// 箭头/直线:用 start→end 语义startPoint 和 endPoint 跟随鼠标
this.board.updateElementPosition(el.id, start.x, start.y)
this.board.updateElementSize(el.id, Math.max(Math.abs(worldPos.x - start.x), 1), Math.max(Math.abs(worldPos.y - start.y), 1))
// 箭头/直线端点跟随鼠标x/y 保持为起点width/height 为线段尺寸
const arrow = el as any
arrow.startPoint = { ...start }
arrow.endPoint = { ...worldPos }
arrow.x = Math.min(start.x, worldPos.x)
arrow.y = Math.min(start.y, worldPos.y)
arrow.width = Math.max(Math.abs(worldPos.x - start.x), 1)
arrow.height = Math.max(Math.abs(worldPos.y - start.y), 1)
} else {
// 矩形/椭圆/菱形/便利贴:标准包围盒绘制
const x = Math.min(start.x, worldPos.x)
const y = Math.min(start.y, worldPos.y)
const width = Math.abs(worldPos.x - start.x)
const height = Math.abs(worldPos.y - start.y)
this.board.updateElementPosition(el.id, x, y)
this.board.updateElementSize(el.id, Math.max(width, 1), Math.max(height, 1))
}
@ -756,6 +795,8 @@ export class BoardInteractionManager {
'nw': 'nw-resize', 'n': 'n-resize', 'ne': 'ne-resize',
'e': 'e-resize', 'se': 'se-resize', 's': 's-resize',
'sw': 'sw-resize', 'w': 'w-resize',
'start': 'crosshair', 'end': 'crosshair',
'drag': 'move',
}
this.canvas.style.cursor = cursorMap[handle]
return
@ -818,6 +859,23 @@ export class BoardInteractionManager {
setBoard(board: BoardModel): void {
this.board = board
}
/** 取消正在绘制的形状ESC 调用) */
cancelDrawing(): void {
if (!this.state.isDrawing) return
// 删除正在绘制的元素
if (this.state.draggedElementId) {
this.board.removeElement(this.state.draggedElementId)
this.selectedIds.delete(this.state.draggedElementId)
}
this.state.isDrawing = false
this.state.wasJustDrawing = false
this.state.drawingStartElement = null
this.state.draggedElementId = null
this.currentTool = 'select'
this.emit('tool:change', { tool: 'select' })
this.updateRendererSelection()
}
}
export default BoardInteractionManager

View File

@ -525,86 +525,105 @@ export class BoardRenderer {
label?: string
): void {
const s = style || {} as ConnectionStyle
const color = s.stroke || '#64748b'
const sw = s.strokeWidth || 2
const route = s.route || 'straight'
this.ctx.strokeStyle = s.stroke || '#64748b'
this.ctx.lineWidth = s.strokeWidth || 2
// 箭头头部尺寸:与描边宽度成正比,最小 8px
const arrowSize = Math.max(8, sw * 4)
// 箭头三角形尾部的精确位置(从端点向后偏移)
const tailOffset = arrowSize / Math.sqrt(3)
// 线段终点偏移:让 round cap 的 sw/2 延伸刚好到达箭头尾部tailOffset 处)
// lineEnd = end - dir * (tailOffset - sw/2),这样 round cap 延伸到 tailOffset
const lineEndOffset = tailOffset - sw * 0.5
const dir = this.getDirection(start, end)
const lineEnd = { x: end.x - dir.x * lineEndOffset, y: end.y - dir.y * lineEndOffset }
const lineStart = start
// 累积路径点(用于计算头部方向)
const allPoints: Point[] = [start, ...(waypoints || []), end]
this.ctx.strokeStyle = color
this.ctx.fillStyle = color
this.ctx.lineWidth = sw
this.ctx.lineCap = 'round'
this.ctx.lineJoin = 'round'
if (s.dashArray) this.ctx.setLineDash(s.dashArray)
const route = s.route || 'orthogonal'
this.ctx.beginPath()
this.ctx.moveTo(start.x, start.y)
if (route === 'straight' || !waypoints?.length) {
// 简单直线
if (route === 'orthogonal' && !waypoints?.length) {
// 正交路由:先水平后垂直
const midX = (start.x + end.x) / 2
this.ctx.lineTo(midX, start.y)
this.ctx.lineTo(midX, end.y)
} else {
// 直线
this.ctx.lineTo(end.x, end.y)
}
// 绘制线段:终点在箭头尾部稍后方,由 round cap 自然延伸到箭头背部
if (waypoints?.length) {
this.ctx.beginPath()
this.ctx.moveTo(start.x, start.y)
waypoints.forEach(wp => this.ctx.lineTo(wp.x, wp.y))
this.ctx.lineTo(lineEnd.x, lineEnd.y)
this.ctx.stroke()
} else if (route === 'orthogonal') {
this.ctx.beginPath()
const midX = (start.x + end.x) / 2
this.ctx.moveTo(start.x, start.y)
this.ctx.lineTo(midX, start.y)
this.ctx.lineTo(midX, end.y)
// 正交路由的终点:取垂直到 y 轴方向
const endDir = this.getDirection({ x: midX, y: end.y }, end)
const orthoEnd = { x: end.x - endDir.x * lineEndOffset, y: end.y - endDir.y * lineEndOffset }
this.ctx.lineTo(orthoEnd.x, orthoEnd.y)
this.ctx.stroke()
} else if (route === 'bezier') {
// 贝塞尔曲线
this.ctx.beginPath()
this.ctx.moveTo(start.x, start.y)
const dx = end.x - start.x
const cp1 = { x: start.x + dx * 0.4, y: start.y }
const cp2 = { x: end.x - dx * 0.4, y: end.y }
this.ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y)
this.ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, lineEnd.x, lineEnd.y)
this.ctx.stroke()
} else {
// 使用 waypoints
waypoints.forEach(wp => this.ctx.lineTo(wp.x, wp.y))
this.ctx.lineTo(end.x, end.y)
// 直线线段终点在箭头尾部后方round cap 自然延伸到箭头背部
const startDir = this.getDirection(end, start)
const extendedStart = { x: lineStart.x - startDir.x * (sw * 0.5), y: lineStart.y - startDir.y * (sw * 0.5) }
this.ctx.beginPath()
this.ctx.moveTo(extendedStart.x, extendedStart.y)
this.ctx.lineTo(lineEnd.x, lineEnd.y)
this.ctx.stroke()
}
this.ctx.stroke()
this.ctx.setLineDash([])
// 起始箭头
if (s.startArrow && s.startArrow !== 'none') {
this.drawArrowHead(start, this.getDirection(start, waypoints?.[0] || end), s.startArrow, s.stroke || '#64748b')
const p1 = allPoints[0] // start
const p2 = allPoints[1] // waypoint 或 end
this.drawArrowHead(p1, this.getDirection(p2, p1), s.startArrow, color, arrowSize)
}
// 终止箭头
// 终止箭头:尖端在 end尾部朝向 lineEnd线段终点
if (s.endArrow && s.endArrow !== 'none') {
const lastPoint = waypoints?.length ? waypoints[waypoints.length - 1] : start
this.drawArrowHead(end, this.getDirection(lastPoint, end), s.endArrow, s.stroke || '#64748b')
this.drawArrowHead(end, dir, s.endArrow, color, arrowSize)
}
// 标签
if (label) {
const midX = (start.x + end.x) / 2
const midY = (start.y + end.y) / 2
this.ctx.fillStyle = s.color || '#1e293b'
this.ctx.font = `${s.fontSize || 12}px sans-serif`
this.ctx.fillStyle = '#ffffff'
this.ctx.font = `${s.fontSize || 12}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
this.ctx.textAlign = 'center'
this.ctx.textBaseline = 'middle'
// 标签背景
const metrics = this.ctx.measureText(label)
const padding = 4
this.ctx.fillStyle = '#ffffff'
this.ctx.fillRect(midX - metrics.width / 2 - padding, midY - 8 - padding, metrics.width + padding * 2, 16 + padding * 2)
this.ctx.fillStyle = s.color || '#1e293b'
this.ctx.fillStyle = s.color || color
this.ctx.fillText(label, midX, midY)
}
}
/** 绘制箭头头部 */
private drawArrowHead(point: Point, direction: Point, type: ArrowEnd, color: string): void {
const size = 10
/** 绘制箭头头部:尖端在 point尖端朝向 direction */
private drawArrowHead(point: Point, direction: Point, type: ArrowEnd, color: string, size = 10): void {
const angle = Math.atan2(direction.y, direction.x)
this.ctx.fillStyle = color
this.ctx.strokeStyle = color
this.ctx.lineWidth = 2
if (type === 'arrow') {
this.ctx.beginPath()
// 尖端在 point尾部在 point 向后偏移处
this.ctx.moveTo(point.x, point.y)
this.ctx.lineTo(
point.x - size * Math.cos(angle - Math.PI / 6),
@ -617,16 +636,23 @@ export class BoardRenderer {
this.ctx.closePath()
this.ctx.fill()
} else if (type === 'circle') {
const r = size * 0.35
this.ctx.beginPath()
this.ctx.arc(point.x, point.y, 4, 0, Math.PI * 2)
this.ctx.arc(point.x, point.y, r, 0, Math.PI * 2)
this.ctx.fill()
} else if (type === 'diamond') {
const s = 6
const s = size * 0.5
this.ctx.beginPath()
this.ctx.moveTo(point.x, point.y)
this.ctx.lineTo(point.x - s * Math.cos(angle) - s * Math.sin(angle), point.y - s * Math.sin(angle) + s * Math.cos(angle))
this.ctx.lineTo(
point.x - s * Math.cos(angle) - s * 0.5 * Math.sin(angle),
point.y - s * Math.sin(angle) + s * 0.5 * Math.cos(angle)
)
this.ctx.lineTo(point.x - 2 * s * Math.cos(angle), point.y - 2 * s * Math.sin(angle))
this.ctx.lineTo(point.x - s * Math.cos(angle) + s * Math.sin(angle), point.y - s * Math.sin(angle) - s * Math.cos(angle))
this.ctx.lineTo(
point.x - s * Math.cos(angle) + s * 0.5 * Math.sin(angle),
point.y - s * Math.sin(angle) - s * 0.5 * Math.cos(angle)
)
this.ctx.closePath()
this.ctx.fill()
}
@ -644,19 +670,67 @@ export class BoardRenderer {
// ===== 选择手柄 =====
private drawSelectionHandles(el: BoardElement): void {
const handleSize = 7 / this.viewport.zoom
// 箭头/线条:绘制线段选中框 + 端点圆形手柄 + 中点手柄
if (el.type === 'arrow' || el.type === 'line') {
const arrow = el as ArrowElement | LineElement
const sp = 'startPoint' in arrow ? arrow.startPoint : { x: el.x, y: el.y }
const ep = 'endPoint' in arrow ? arrow.endPoint : { x: el.x + el.width, y: el.y }
const midX = (sp.x + ep.x) / 2
const midY = (sp.y + ep.y) / 2
// 线段选中框(蓝色实线)
this.ctx.strokeStyle = '#3b82f6'
this.ctx.lineWidth = 1.5 / this.viewport.zoom
this.ctx.setLineDash([])
this.ctx.beginPath()
this.ctx.moveTo(sp.x, sp.y)
this.ctx.lineTo(ep.x, ep.y)
this.ctx.stroke()
// 端点手柄:白色填充 + 蓝色描边 + 阴影Excalidraw 风格)
const r = (handleSize / 2) * 1.2
;[sp, ep].forEach(pt => {
this.ctx.save()
this.ctx.shadowColor = 'rgba(59, 130, 246, 0.4)'
this.ctx.shadowBlur = 4 / this.viewport.zoom
this.ctx.shadowOffsetX = 0
this.ctx.shadowOffsetY = 1 / this.viewport.zoom
this.ctx.fillStyle = '#ffffff'
this.ctx.strokeStyle = '#3b82f6'
this.ctx.lineWidth = 1.5 / this.viewport.zoom
this.ctx.beginPath()
this.ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)
this.ctx.fill()
this.ctx.stroke()
this.ctx.restore()
})
// 中点手柄蓝色实心圆Excalidraw 风格,方便拖拽整条线)
const midR = (handleSize / 2) * 0.8
this.ctx.save()
this.ctx.fillStyle = '#3b82f6'
this.ctx.strokeStyle = '#ffffff'
this.ctx.lineWidth = 1.5 / this.viewport.zoom
this.ctx.beginPath()
this.ctx.arc(midX, midY, midR, 0, Math.PI * 2)
this.ctx.fill()
this.ctx.stroke()
this.ctx.restore()
return
}
// 普通形状:绘制包围盒 + 8 个方形手柄
const { x, y, width, height } = el
const padding = 4 / this.viewport.zoom
// 选中框
this.ctx.strokeStyle = '#3b82f6'
this.ctx.lineWidth = 1.5 / this.viewport.zoom
this.ctx.setLineDash([])
this.ctx.strokeRect(x - padding, y - padding, width + padding * 2, height + padding * 2)
// Resize 手柄
const handles = this.getResizeHandles(el)
const handleSize = 7 / this.viewport.zoom
handles.forEach(handle => {
this.ctx.fillStyle = '#ffffff'
this.ctx.strokeStyle = '#3b82f6'
@ -682,8 +756,22 @@ export class BoardRenderer {
/** 获取 Resize 手柄位置 */
getResizeHandles(el: BoardElement): Map<ResizeHandle, Point> {
const { x, y, width, height } = el
const handles = new Map<ResizeHandle, Point>()
// 箭头/线条:使用线段端点 + 中点作为手柄
if (el.type === 'arrow' || el.type === 'line') {
const arrow = el as ArrowElement | LineElement
const sp = 'startPoint' in arrow ? arrow.startPoint : { x: el.x, y: el.y }
const ep = 'endPoint' in arrow ? arrow.endPoint : { x: el.x + el.width, y: el.y }
handles.set('start', { ...sp })
handles.set('end', { ...ep })
// 中点(方便拖拽中间区域)
handles.set('n', { x: (sp.x + ep.x) / 2, y: (sp.y + ep.y) / 2 })
return handles
}
// 普通形状:使用包围盒角点
const { x, y, width, height } = el
handles.set('nw', { x, y })
handles.set('n', { x: x + width / 2, y })
handles.set('ne', { x: x + width, y })
@ -696,9 +784,31 @@ export class BoardRenderer {
}
/** 检测点击了哪个 Resize 手柄 */
hitTestResizeHandle(el: BoardElement, worldPos: Point): ResizeHandle | null {
hitTestResizeHandle(el: BoardElement, worldPos: Point): ResizeHandle | 'drag' | null {
const handles = this.getResizeHandles(el)
const hitRadius = 8 / this.viewport.zoom
// 箭头/线条:先检测是否点击在端点或中点手柄
if (el.type === 'arrow' || el.type === 'line') {
for (const [handle, pos] of handles) {
const dx = worldPos.x - pos.x
const dy = worldPos.y - pos.y
if (dx * dx + dy * dy <= hitRadius * hitRadius) {
return handle // 'start' | 'end' | 'n'(中点)
}
}
// 手柄都没命中,检测是否点击在线条本身上
const arrow = el as ArrowElement | LineElement
const sp = (arrow as any).startPoint ?? { x: el.x, y: el.y }
const ep = (arrow as any).endPoint ?? { x: el.x + el.width, y: el.y }
const lineHitRadius = 8 / this.viewport.zoom
if (this.distToSegment(worldPos, sp, ep) <= lineHitRadius) {
return 'drag' // 点击在线上(非手柄)→ 整条拖拽
}
return null
}
// 普通形状:检测 8 个角点手柄
for (const [handle, pos] of handles) {
const dx = worldPos.x - pos.x
const dy = worldPos.y - pos.y
@ -709,6 +819,16 @@ export class BoardRenderer {
return null
}
/** 点到线段的距离 */
private distToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x
const dy = b.y - a.y
const lenSq = dx * dx + dy * dy
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y)
let t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
}
// ===== 辅助绘制 =====
/** 圆角矩形 */

View File

@ -460,6 +460,7 @@ export function useWhiteboard(options: UseWhiteboardOptions = {}) {
clearHistory,
// 其他
resize,
cancelDrawing: () => interaction.value?.cancelDrawing(),
// 便利贴颜色
stickyNoteColors: STICKY_COLORS,
}