implement
This commit is contained in:
@@ -1,18 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
|
||||
import type { Rectangle } from '@/types/whiteboard.ts'
|
||||
import type { Arrow, Line, Rectangle, TextShape } from '@/types/whiteboard.ts'
|
||||
import type { ShapeType } from '@/types/whiteboard.ts'
|
||||
import SvgRectangle from '@/components/whiteboard/shapes/SvgRectangle.vue'
|
||||
import SvgDraftRect from '@/components/whiteboard/shapes/SvgDraftRect.vue'
|
||||
import SvgArrow from '@/components/whiteboard/shapes/SvgArrow.vue'
|
||||
import SvgDraftArrow from '@/components/whiteboard/shapes/SvgDraftArrow.vue'
|
||||
import SvgLine from '@/components/whiteboard/shapes/SvgLine.vue'
|
||||
import SvgDraftLine from '@/components/whiteboard/shapes/SvgDraftLine.vue'
|
||||
import SvgTextShape from '@/components/whiteboard/shapes/SvgTextShape.vue'
|
||||
import SvgDraftText from '@/components/whiteboard/shapes/SvgDraftText.vue'
|
||||
import ShapeOwnerLabel from '@/components/whiteboard/ShapeOwnerLabel.vue'
|
||||
import ShapeOwnerTooltip from '@/components/whiteboard/ShapeOwnerTooltip.vue'
|
||||
import { whiteboardHubService } from '@/services/whiteboardHubService.ts'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const store = useWhiteboardStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const isMoving = ref(false)
|
||||
const moveStartMouse = ref({ x: 0, y: 0 })
|
||||
const moveStartPos = ref({ x: 0, y: 0 })
|
||||
const moveStartEndPos = ref({ x: 0, y: 0 })
|
||||
const movingShapeId = ref<string | null>(null)
|
||||
let lastMoveHubTime = 0
|
||||
|
||||
const hoveredShapeId = ref<string | null>(null)
|
||||
const hoveredOwnerId = ref<string | null>(null)
|
||||
const cursorX = ref(0)
|
||||
const cursorY = ref(0)
|
||||
|
||||
function findOwnerIdByShapeId(id: string): string | null {
|
||||
const wb = store.whiteboard
|
||||
if (!wb) return null
|
||||
const all = [
|
||||
...wb.rectangles,
|
||||
...wb.arrows,
|
||||
...wb.lines,
|
||||
...wb.textShapes,
|
||||
]
|
||||
return all.find(s => s.id === id)?.ownerId ?? null
|
||||
}
|
||||
|
||||
const selectedShapeAnchor = computed(() => {
|
||||
const shape = store.selectedShape
|
||||
if (!shape) return null
|
||||
const type = store.selectedShapeType
|
||||
|
||||
if (type === 'rectangle') {
|
||||
const r = shape as Rectangle
|
||||
return {
|
||||
x: (r.position.x + r.endPosition.x) / 2,
|
||||
y: Math.min(r.position.y, r.endPosition.y),
|
||||
}
|
||||
}
|
||||
if (type === 'arrow' || type === 'line') {
|
||||
const s = shape as Arrow | Line
|
||||
return {
|
||||
x: (s.position.x + s.endPosition.x) / 2,
|
||||
y: Math.min(s.position.y, s.endPosition.y),
|
||||
}
|
||||
}
|
||||
if (type === 'textShape') {
|
||||
const t = shape as TextShape
|
||||
return { x: t.position.x, y: t.position.y }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ x: 0, y: 0 })
|
||||
const dragEnd = ref({ x: 0, y: 0 })
|
||||
|
||||
const textInputState = ref({ active: false, x: 0, y: 0, value: '', textSize: 24 })
|
||||
const textInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const draftRect = computed(() => {
|
||||
if (!isDragging.value) return null
|
||||
if (!isDragging.value || store.selectedTool !== 'rectangle') return null
|
||||
const x = Math.min(dragStart.value.x, dragEnd.value.x)
|
||||
const y = Math.min(dragStart.value.y, dragEnd.value.y)
|
||||
const width = Math.abs(dragEnd.value.x - dragStart.value.x)
|
||||
@@ -20,9 +85,77 @@ const draftRect = computed(() => {
|
||||
return { x, y, width, height }
|
||||
})
|
||||
|
||||
const draftArrow = computed(() => {
|
||||
if (!isDragging.value || store.selectedTool !== 'arrow') return null
|
||||
return {
|
||||
x1: dragStart.value.x,
|
||||
y1: dragStart.value.y,
|
||||
x2: dragEnd.value.x,
|
||||
y2: dragEnd.value.y,
|
||||
}
|
||||
})
|
||||
|
||||
const draftLine = computed(() => {
|
||||
if (!isDragging.value || store.selectedTool !== 'line') return null
|
||||
return {
|
||||
x1: dragStart.value.x,
|
||||
y1: dragStart.value.y,
|
||||
x2: dragEnd.value.x,
|
||||
y2: dragEnd.value.y,
|
||||
}
|
||||
})
|
||||
|
||||
const showDraftText = computed(() => {
|
||||
return textInputState.value.active && textInputState.value.value.length > 0
|
||||
})
|
||||
|
||||
function getCanvasCoords(e: MouseEvent) {
|
||||
const el = (e.currentTarget as HTMLElement).querySelector('svg') ?? (e.currentTarget as SVGSVGElement)
|
||||
const rect = el.getBoundingClientRect()
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (store.selectedTool !== 'rectangle') return
|
||||
const svg = (e.currentTarget as SVGSVGElement)
|
||||
if (textInputState.value.active) return
|
||||
|
||||
if (store.selectedTool === 'hand') {
|
||||
const shapeEl = (e.target as Element).closest('[data-shape-id]')
|
||||
if (shapeEl) {
|
||||
const shapeId = shapeEl.getAttribute('data-shape-id')!
|
||||
const shapeType = shapeEl.getAttribute('data-shape-type')! as ShapeType
|
||||
store.selectShape(shapeId, shapeType)
|
||||
|
||||
const ownerId = findOwnerIdByShapeId(shapeId)
|
||||
if (ownerId === auth.user?.userId) {
|
||||
const { x, y } = getCanvasCoords(e)
|
||||
moveStartMouse.value = { x, y }
|
||||
|
||||
const shape = store.selectedShape
|
||||
if (shape) {
|
||||
moveStartPos.value = { x: shape.position.x, y: shape.position.y }
|
||||
if ('endPosition' in shape) {
|
||||
moveStartEndPos.value = { x: (shape as any).endPosition.x, y: (shape as any).endPosition.y }
|
||||
}
|
||||
isMoving.value = true
|
||||
movingShapeId.value = shapeId
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.deselectShape()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (store.selectedTool === 'text') {
|
||||
e.preventDefault()
|
||||
const { x, y } = getCanvasCoords(e)
|
||||
textInputState.value = { active: true, x, y, value: '', textSize: store.toolTextSize }
|
||||
nextTick(() => textInputRef.value?.focus())
|
||||
return
|
||||
}
|
||||
|
||||
if (store.selectedTool !== 'rectangle' && store.selectedTool !== 'arrow' && store.selectedTool !== 'line') return
|
||||
const svg = (e.target as Element).closest('svg') as SVGSVGElement
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
@@ -32,69 +165,319 @@ function onMouseDown(e: MouseEvent) {
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
const svg = (e.currentTarget as SVGSVGElement)
|
||||
const rect = svg.getBoundingClientRect()
|
||||
dragEnd.value = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
if (isMoving.value && movingShapeId.value) {
|
||||
const { x, y } = getCanvasCoords(e)
|
||||
const dx = x - moveStartMouse.value.x
|
||||
const dy = y - moveStartMouse.value.y
|
||||
const newPosX = moveStartPos.value.x + dx
|
||||
const newPosY = moveStartPos.value.y + dy
|
||||
store.applyMoveShape(movingShapeId.value, newPosX, newPosY)
|
||||
|
||||
hoveredShapeId.value = null
|
||||
hoveredOwnerId.value = null
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastMoveHubTime >= 30) {
|
||||
whiteboardHubService.moveShape({ shapeId: movingShapeId.value, newPositionX: newPosX, newPositionY: newPosY })
|
||||
lastMoveHubTime = now
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
const svg = (e.target as Element).closest('svg') as SVGSVGElement
|
||||
if (!svg) return
|
||||
const rect = svg.getBoundingClientRect()
|
||||
dragEnd.value = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (store.selectedTool === 'hand') {
|
||||
const shapeEl = (e.target as Element).closest('[data-shape-id]')
|
||||
if (shapeEl) {
|
||||
const id = shapeEl.getAttribute('data-shape-id')!
|
||||
if (id !== store.selectedShapeId) {
|
||||
hoveredShapeId.value = id
|
||||
hoveredOwnerId.value = findOwnerIdByShapeId(id)
|
||||
cursorX.value = e.clientX
|
||||
cursorY.value = e.clientY
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hoveredShapeId.value = null
|
||||
hoveredOwnerId.value = null
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (isMoving.value && movingShapeId.value) {
|
||||
const shape = store.whiteboard
|
||||
? [...store.whiteboard.rectangles, ...store.whiteboard.arrows, ...store.whiteboard.lines, ...store.whiteboard.textShapes].find(s => s.id === movingShapeId.value)
|
||||
: null
|
||||
const dx = Math.abs(moveStartPos.value.x - (shape?.position.x ?? moveStartPos.value.x))
|
||||
const dy = Math.abs(moveStartPos.value.y - (shape?.position.y ?? moveStartPos.value.y))
|
||||
if (dx > 2 || dy > 2) {
|
||||
whiteboardHubService.placeShape({
|
||||
shapeId: movingShapeId.value,
|
||||
newPositionX: shape?.position.x ?? moveStartPos.value.x,
|
||||
newPositionY: shape?.position.y ?? moveStartPos.value.y,
|
||||
})
|
||||
}
|
||||
isMoving.value = false
|
||||
movingShapeId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
|
||||
const x1 = Math.min(dragStart.value.x, dragEnd.value.x)
|
||||
const y1 = Math.min(dragStart.value.y, dragEnd.value.y)
|
||||
const x2 = Math.max(dragStart.value.x, dragEnd.value.x)
|
||||
const y2 = Math.max(dragStart.value.y, dragEnd.value.y)
|
||||
const sx = dragStart.value.x
|
||||
const sy = dragStart.value.y
|
||||
const ex = dragEnd.value.x
|
||||
const ey = dragEnd.value.y
|
||||
|
||||
if (x2 - x1 < 5 && y2 - y1 < 5) return
|
||||
const dx = Math.abs(ex - sx)
|
||||
const dy = Math.abs(ey - sy)
|
||||
if (dx < 5 && dy < 5) return
|
||||
|
||||
const rectangle: Rectangle = {
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: '00000000-0000-0000-0000-000000000000',
|
||||
position: { x: Math.round(x1), y: Math.round(y1) },
|
||||
endPosition: { x: Math.round(x2), y: Math.round(y2) },
|
||||
color: '#4f9dff',
|
||||
borderThickness: 2,
|
||||
if (store.selectedTool === 'rectangle') {
|
||||
const rectangle: Rectangle = {
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: auth.user?.userId ?? '',
|
||||
position: { x: Math.round(Math.min(sx, ex)), y: Math.round(Math.min(sy, ey)) },
|
||||
endPosition: { x: Math.round(Math.max(sx, ex)), y: Math.round(Math.max(sy, ey)) },
|
||||
color: store.toolColor,
|
||||
borderThickness: store.toolThickness,
|
||||
}
|
||||
store.addRectangle(rectangle)
|
||||
store.selectTool('hand')
|
||||
} else if (store.selectedTool === 'arrow') {
|
||||
const arrow: Arrow = {
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: auth.user?.userId ?? '',
|
||||
position: { x: Math.round(sx), y: Math.round(sy) },
|
||||
endPosition: { x: Math.round(ex), y: Math.round(ey) },
|
||||
color: store.toolColor,
|
||||
thickness: store.toolThickness,
|
||||
}
|
||||
store.addArrow(arrow)
|
||||
store.selectTool('hand')
|
||||
} else if (store.selectedTool === 'line') {
|
||||
const line: Line = {
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: auth.user?.userId ?? '',
|
||||
position: { x: Math.round(sx), y: Math.round(sy) },
|
||||
endPosition: { x: Math.round(ex), y: Math.round(ey) },
|
||||
color: store.toolColor,
|
||||
thickness: store.toolThickness,
|
||||
}
|
||||
store.addLine(line)
|
||||
store.selectTool('hand')
|
||||
}
|
||||
}
|
||||
|
||||
store.addRectangle(rectangle)
|
||||
function commitTextShape() {
|
||||
if (!textInputState.value.active) return
|
||||
const hasText = textInputState.value.value.trim().length > 0
|
||||
if (hasText) {
|
||||
const textShape: TextShape = {
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: auth.user?.userId ?? '',
|
||||
position: { x: Math.round(textInputState.value.x), y: Math.round(textInputState.value.y) },
|
||||
color: store.toolColor,
|
||||
textValue: textInputState.value.value,
|
||||
textSize: textInputState.value.textSize,
|
||||
}
|
||||
store.addTextShape(textShape)
|
||||
}
|
||||
cancelTextInput()
|
||||
if (hasText) {
|
||||
store.selectTool('hand')
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTextInput() {
|
||||
textInputState.value = { active: false, x: 0, y: 0, value: '', textSize: store.toolTextSize }
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (isMoving.value && movingShapeId.value) {
|
||||
const shape = store.whiteboard
|
||||
? [...store.whiteboard.rectangles, ...store.whiteboard.arrows, ...store.whiteboard.lines, ...store.whiteboard.textShapes].find(s => s.id === movingShapeId.value)
|
||||
: null
|
||||
const dx = Math.abs(moveStartPos.value.x - (shape?.position.x ?? moveStartPos.value.x))
|
||||
const dy = Math.abs(moveStartPos.value.y - (shape?.position.y ?? moveStartPos.value.y))
|
||||
if (dx > 2 || dy > 2) {
|
||||
whiteboardHubService.placeShape({
|
||||
shapeId: movingShapeId.value,
|
||||
newPositionX: shape?.position.x ?? moveStartPos.value.x,
|
||||
newPositionY: shape?.position.y ?? moveStartPos.value.y,
|
||||
})
|
||||
}
|
||||
isMoving.value = false
|
||||
movingShapeId.value = null
|
||||
}
|
||||
onMouseUp()
|
||||
hoveredShapeId.value = null
|
||||
hoveredOwnerId.value = null
|
||||
}
|
||||
|
||||
function onTextInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitTextShape()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelTextInput()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="whiteboard-canvas"
|
||||
<div
|
||||
class="canvas-container"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<SvgRectangle
|
||||
v-for="rect in store.whiteboard?.rectangles"
|
||||
:key="rect.id"
|
||||
:rectangle="rect"
|
||||
<svg :class="['whiteboard-canvas', store.selectedTool === 'hand' ? 'select-mode' : 'draw-mode']">
|
||||
<SvgRectangle
|
||||
v-for="rect in store.whiteboard?.rectangles"
|
||||
:key="rect.id"
|
||||
:rectangle="rect"
|
||||
:is-selected="store.selectedShapeId === rect.id"
|
||||
/>
|
||||
<SvgArrow
|
||||
v-for="arrow in store.whiteboard?.arrows"
|
||||
:key="arrow.id"
|
||||
:arrow="arrow"
|
||||
:is-selected="store.selectedShapeId === arrow.id"
|
||||
/>
|
||||
<SvgLine
|
||||
v-for="line in store.whiteboard?.lines"
|
||||
:key="line.id"
|
||||
:line="line"
|
||||
:is-selected="store.selectedShapeId === line.id"
|
||||
/>
|
||||
<SvgTextShape
|
||||
v-for="ts in store.whiteboard?.textShapes"
|
||||
:key="ts.id"
|
||||
:text-shape="ts"
|
||||
:is-selected="store.selectedShapeId === ts.id"
|
||||
/>
|
||||
<ShapeOwnerLabel
|
||||
v-if="store.selectedShape && selectedShapeAnchor"
|
||||
:owner-id="store.selectedShape.ownerId"
|
||||
:anchor-x="selectedShapeAnchor.x"
|
||||
:anchor-y="selectedShapeAnchor.y"
|
||||
/>
|
||||
<SvgDraftRect
|
||||
v-if="draftRect"
|
||||
:x="draftRect.x"
|
||||
:y="draftRect.y"
|
||||
:width="draftRect.width"
|
||||
:height="draftRect.height"
|
||||
:color="store.toolColor"
|
||||
:thickness="store.toolThickness"
|
||||
/>
|
||||
<SvgDraftArrow
|
||||
v-if="draftArrow"
|
||||
:x1="draftArrow.x1"
|
||||
:y1="draftArrow.y1"
|
||||
:x2="draftArrow.x2"
|
||||
:y2="draftArrow.y2"
|
||||
:color="store.toolColor"
|
||||
:thickness="store.toolThickness"
|
||||
/>
|
||||
<SvgDraftLine
|
||||
v-if="draftLine"
|
||||
:x1="draftLine.x1"
|
||||
:y1="draftLine.y1"
|
||||
:x2="draftLine.x2"
|
||||
:y2="draftLine.y2"
|
||||
:color="store.toolColor"
|
||||
:thickness="store.toolThickness"
|
||||
/>
|
||||
<SvgDraftText
|
||||
v-if="showDraftText"
|
||||
:x="textInputState.x"
|
||||
:y="textInputState.y"
|
||||
:text-value="textInputState.value"
|
||||
:text-size="textInputState.textSize"
|
||||
:color="store.toolColor"
|
||||
/>
|
||||
</svg>
|
||||
<ShapeOwnerTooltip
|
||||
v-if="hoveredOwnerId && hoveredShapeId"
|
||||
:owner-id="hoveredOwnerId"
|
||||
:cursor-x="cursorX"
|
||||
:cursor-y="cursorY"
|
||||
/>
|
||||
<SvgDraftRect
|
||||
v-if="draftRect"
|
||||
:x="draftRect.x"
|
||||
:y="draftRect.y"
|
||||
:width="draftRect.width"
|
||||
:height="draftRect.height"
|
||||
<input
|
||||
v-if="textInputState.active"
|
||||
ref="textInputRef"
|
||||
v-model="textInputState.value"
|
||||
class="text-input-overlay"
|
||||
:style="{
|
||||
left: textInputState.x + 'px',
|
||||
top: textInputState.y + 'px',
|
||||
fontSize: textInputState.textSize + 'px',
|
||||
borderColor: store.toolColor,
|
||||
color: store.toolColor,
|
||||
}"
|
||||
@blur="commitTextShape"
|
||||
@keydown="onTextInputKeydown"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.whiteboard-canvas {
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.whiteboard-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #1a1a2e;
|
||||
cursor: crosshair;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.whiteboard-canvas.draw-mode {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.whiteboard-canvas.draw-mode :deep([data-shape-id]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.whiteboard-canvas.select-mode {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.whiteboard-canvas.select-mode :deep([data-shape-id]) {
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-input-overlay {
|
||||
position: absolute;
|
||||
background: rgba(31, 31, 47, 0.95);
|
||||
border: 2px solid #4f9dff;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: #4f9dff;
|
||||
font-family: Arial, sans-serif;
|
||||
outline: none;
|
||||
min-width: 150px;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user