优化whiteboard的箭头和直线功能
This commit is contained in:
parent
f051a077e0
commit
1c93d113da
@ -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>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/** 更新元素尺寸 */
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
// ===== 辅助绘制 =====
|
||||
|
||||
/** 圆角矩形 */
|
||||
|
||||
@ -460,6 +460,7 @@ export function useWhiteboard(options: UseWhiteboardOptions = {}) {
|
||||
clearHistory,
|
||||
// 其他
|
||||
resize,
|
||||
cancelDrawing: () => interaction.value?.cancelDrawing(),
|
||||
// 便利贴颜色
|
||||
stickyNoteColors: STICKY_COLORS,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user