frontend whiteboard canvas with rectangle support

This commit is contained in:
2026-02-16 16:13:43 +01:00
parent c200847c17
commit 0f0418dee3
8 changed files with 417 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
import type { Rectangle } from '@/types/whiteboard.ts'
import SvgRectangle from '@/components/whiteboard/shapes/SvgRectangle.vue'
import SvgDraftRect from '@/components/whiteboard/shapes/SvgDraftRect.vue'
const store = useWhiteboardStore()
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const dragEnd = ref({ x: 0, y: 0 })
const draftRect = computed(() => {
if (!isDragging.value) 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)
const height = Math.abs(dragEnd.value.y - dragStart.value.y)
return { x, y, width, height }
})
function onMouseDown(e: MouseEvent) {
if (store.selectedTool !== 'rectangle') return
const svg = (e.currentTarget as SVGSVGElement)
const rect = svg.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
dragStart.value = { x, y }
dragEnd.value = { x, y }
isDragging.value = true
}
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,
}
}
function onMouseUp() {
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)
if (x2 - x1 < 5 && y2 - y1 < 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,
}
store.addRectangle(rectangle)
}
</script>
<template>
<svg
class="whiteboard-canvas"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<SvgRectangle
v-for="rect in store.whiteboard?.rectangles"
:key="rect.id"
:rectangle="rect"
/>
<SvgDraftRect
v-if="draftRect"
:x="draftRect.x"
:y="draftRect.y"
:width="draftRect.width"
:height="draftRect.height"
/>
</svg>
</template>
<style scoped>
.whiteboard-canvas {
flex: 1;
width: 100%;
height: 100%;
background-color: #1a1a2e;
cursor: crosshair;
display: block;
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
import type { ShapeTool } from '@/types/whiteboard.ts'
const store = useWhiteboardStore()
const emit = defineEmits<{ leave: [] }>()
const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[] = [
{ name: 'rectangle', label: 'Rectangle', icon: '▭', enabled: true },
{ name: 'arrow', label: 'Arrow', icon: '→', enabled: false },
{ name: 'line', label: 'Line', icon: '', enabled: false },
{ name: 'text', label: 'Text', icon: 'T', enabled: false },
]
</script>
<template>
<div class="toolbar d-flex flex-column align-items-center py-2 gap-2">
<button
v-for="tool in tools"
:key="tool.name"
class="btn btn-sm"
:class="[
store.selectedTool === tool.name ? 'btn-primary' : 'btn-outline-secondary',
{ disabled: !tool.enabled },
]"
:disabled="!tool.enabled"
:title="tool.enabled ? tool.label : `${tool.label} (coming soon)`"
style="width: 40px; height: 40px; font-size: 1.1rem"
@click="tool.enabled && store.selectTool(tool.name)"
>
{{ tool.icon }}
</button>
<div class="mt-auto mb-2">
<button
class="btn btn-sm btn-outline-danger"
title="Leave whiteboard"
style="width: 40px; height: 40px"
@click="emit('leave')"
>
</button>
</div>
</div>
</template>
<style scoped>
.toolbar {
width: 56px;
background-color: #0d0d1a;
border-right: 1px solid #2a2a3e;
height: 100%;
}
</style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
const props = defineProps<{
x: number
y: number
width: number
height: number
}>()
</script>
<template>
<rect
:x="props.x"
:y="props.y"
:width="props.width"
:height="props.height"
stroke="#4f9dff"
:stroke-width="2"
stroke-dasharray="6 3"
fill="none"
opacity="0.7"
/>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { Rectangle } from '@/types/whiteboard.ts'
const props = defineProps<{ rectangle: Rectangle }>()
</script>
<template>
<rect
:x="Math.min(props.rectangle.position.x, props.rectangle.endPosition.x)"
:y="Math.min(props.rectangle.position.y, props.rectangle.endPosition.y)"
:width="Math.abs(props.rectangle.endPosition.x - props.rectangle.position.x)"
:height="Math.abs(props.rectangle.endPosition.y - props.rectangle.position.y)"
:stroke="props.rectangle.color"
:stroke-width="props.rectangle.borderThickness"
fill="none"
/>
</template>

View File

@@ -0,0 +1,49 @@
import { SignalRService } from '@/services/signalr.ts'
import type { Rectangle, Whiteboard } from '@/types/whiteboard.ts'
const client = new SignalRService(`http://localhost:5039/whiteboardhub`)
export const whiteboardHubService = {
async connect() {
await client.start()
},
async disconnect() {
await client.stop()
},
async joinWhiteboard(id: string) {
await client.invoke('JoinWhiteboard', id)
},
async leaveWhiteboard(id: string) {
await client.invoke('LeaveWhiteboard', id)
},
async addRectangle(rectangle: Rectangle) {
await client.invoke('AddRectangle', rectangle)
},
onInitWhiteboard(callback: (whiteboard: Whiteboard) => void) {
client.on<Whiteboard>('InitWhiteboard', callback)
},
onAddedRectangle(callback: (rectangle: Rectangle) => void) {
client.on<Rectangle>('AddedRectangle', callback)
},
onJoined(callback: (userId: string) => void) {
client.on<string>('Joined', callback)
},
onLeaved(callback: (userId: string) => void) {
client.on<string>('Leaved', callback)
},
offAll() {
client.off('InitWhiteboard')
client.off('AddedRectangle')
client.off('Joined')
client.off('Leaved')
},
}

View File

@@ -0,0 +1,88 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { Rectangle, ShapeTool, Whiteboard } from '@/types/whiteboard.ts'
import { whiteboardHubService } from '@/services/whiteboardHubService.ts'
export const useWhiteboardStore = defineStore('whiteboard', () => {
const whiteboard = ref<Whiteboard | null>(null)
const selectedTool = ref<ShapeTool>('rectangle')
const isConnected = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function joinWhiteboard(id: string) {
isLoading.value = true
error.value = null
try {
await whiteboardHubService.connect()
isConnected.value = true
whiteboardHubService.onInitWhiteboard((wb) => {
whiteboard.value = wb
isLoading.value = false
})
whiteboardHubService.onAddedRectangle((rectangle) => {
whiteboard.value?.rectangles.push(rectangle)
})
whiteboardHubService.onJoined((userId) => {
console.log('User joined:', userId)
})
whiteboardHubService.onLeaved((userId) => {
console.log('User left:', userId)
})
await whiteboardHubService.joinWhiteboard(id)
} catch (e: any) {
error.value = e?.message ?? 'Failed to join whiteboard'
isLoading.value = false
}
}
async function leaveWhiteboard() {
if (!whiteboard.value) return
try {
await whiteboardHubService.leaveWhiteboard(whiteboard.value.whiteboardId)
} catch (e) {
console.warn('Leave request failed', e)
}
whiteboardHubService.offAll()
await whiteboardHubService.disconnect()
whiteboard.value = null
isConnected.value = false
selectedTool.value = 'rectangle'
error.value = null
}
async function addRectangle(rectangle: Rectangle) {
whiteboard.value?.rectangles.push(rectangle)
try {
await whiteboardHubService.addRectangle(rectangle)
} catch (e: any) {
console.error('Failed to send rectangle', e)
}
}
function selectTool(tool: ShapeTool) {
selectedTool.value = tool
}
return {
whiteboard,
selectedTool,
isConnected,
isLoading,
error,
joinWhiteboard,
leaveWhiteboard,
addRectangle,
selectTool,
}
})

View File

@@ -0,0 +1,42 @@
export interface Position {
x: number
y: number
}
export interface Shape {
id: string
ownerId: string
position: Position
color: string
}
export interface Rectangle extends Shape {
endPosition: Position
borderThickness: number
}
export interface Arrow extends Shape {
endPosition: Position
thickness: number
}
export interface Line extends Shape {
endPosition: Position
thickness: number
}
export interface TextShape extends Shape {
textValue: string
textSize: number
}
export interface Whiteboard {
whiteboardId: string
ownerId: string
rectangles: Rectangle[]
arrows: Arrow[]
lines: Line[]
textShapes: TextShape[]
}
export type ShapeTool = 'rectangle' | 'arrow' | 'line' | 'text'

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
import WhiteboardToolbar from '@/components/whiteboard/WhiteboardToolbar.vue'
import WhiteboardCanvas from '@/components/whiteboard/WhiteboardCanvas.vue'
const route = useRoute()
const router = useRouter()
const store = useWhiteboardStore()
const whiteboardId = route.params.id as string
onMounted(() => {
store.joinWhiteboard(whiteboardId)
})
onUnmounted(() => {
store.leaveWhiteboard()
})
async function handleLeave() {
await store.leaveWhiteboard()
router.push('/test')
}
</script>
<template>
<div v-if="store.isLoading" class="d-flex justify-content-center align-items-center vh-100">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else-if="store.error" class="d-flex justify-content-center align-items-center vh-100">
<div class="alert alert-danger" role="alert">
{{ store.error }}
</div>
</div>
<div v-else class="d-flex vh-100">
<WhiteboardToolbar @leave="handleLeave" />
<WhiteboardCanvas />
</div>
</template>