+
@@ -46,9 +135,96 @@ const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[
diff --git a/front/src/components/whiteboard/shapes/SvgArrow.vue b/front/src/components/whiteboard/shapes/SvgArrow.vue
new file mode 100644
index 0000000..f485be6
--- /dev/null
+++ b/front/src/components/whiteboard/shapes/SvgArrow.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/components/whiteboard/shapes/SvgDraftArrow.vue b/front/src/components/whiteboard/shapes/SvgDraftArrow.vue
new file mode 100644
index 0000000..bbc45bc
--- /dev/null
+++ b/front/src/components/whiteboard/shapes/SvgDraftArrow.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/components/whiteboard/shapes/SvgDraftLine.vue b/front/src/components/whiteboard/shapes/SvgDraftLine.vue
new file mode 100644
index 0000000..808c91f
--- /dev/null
+++ b/front/src/components/whiteboard/shapes/SvgDraftLine.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/front/src/components/whiteboard/shapes/SvgDraftRect.vue b/front/src/components/whiteboard/shapes/SvgDraftRect.vue
index 17632e1..0780996 100644
--- a/front/src/components/whiteboard/shapes/SvgDraftRect.vue
+++ b/front/src/components/whiteboard/shapes/SvgDraftRect.vue
@@ -4,6 +4,8 @@ const props = defineProps<{
y: number
width: number
height: number
+ color: string
+ thickness: number
}>()
@@ -13,8 +15,8 @@ const props = defineProps<{
:y="props.y"
:width="props.width"
:height="props.height"
- stroke="#4f9dff"
- :stroke-width="2"
+ :stroke="props.color"
+ :stroke-width="props.thickness"
stroke-dasharray="6 3"
fill="none"
opacity="0.7"
diff --git a/front/src/components/whiteboard/shapes/SvgDraftText.vue b/front/src/components/whiteboard/shapes/SvgDraftText.vue
new file mode 100644
index 0000000..1579a8d
--- /dev/null
+++ b/front/src/components/whiteboard/shapes/SvgDraftText.vue
@@ -0,0 +1,23 @@
+
+
+
+
+ {{ props.textValue }}
+
+
diff --git a/front/src/components/whiteboard/shapes/SvgLine.vue b/front/src/components/whiteboard/shapes/SvgLine.vue
new file mode 100644
index 0000000..c41776b
--- /dev/null
+++ b/front/src/components/whiteboard/shapes/SvgLine.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/components/whiteboard/shapes/SvgRectangle.vue b/front/src/components/whiteboard/shapes/SvgRectangle.vue
index 95aedb3..cab8ba9 100644
--- a/front/src/components/whiteboard/shapes/SvgRectangle.vue
+++ b/front/src/components/whiteboard/shapes/SvgRectangle.vue
@@ -1,17 +1,43 @@
-
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/components/whiteboard/shapes/SvgTextShape.vue b/front/src/components/whiteboard/shapes/SvgTextShape.vue
new file mode 100644
index 0000000..5ba7ccf
--- /dev/null
+++ b/front/src/components/whiteboard/shapes/SvgTextShape.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+ {{ props.textShape.textValue }}
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/composables/useShapeOwner.ts b/front/src/composables/useShapeOwner.ts
new file mode 100644
index 0000000..0e630a6
--- /dev/null
+++ b/front/src/composables/useShapeOwner.ts
@@ -0,0 +1,51 @@
+import { ref, watch, type Ref } from 'vue'
+import { useAuthStore } from '@/stores/auth'
+import { fetchUser } from '@/services/userService'
+
+function avatarColorFromString(str: string): string {
+ let hash = 5381
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xffffffff
+ }
+ return `hsl(${Math.abs(hash) % 360}, 65%, 55%)`
+}
+
+export function useShapeOwner(ownerId: Ref
) {
+ const auth = useAuthStore()
+
+ const displayName = ref('...')
+ const avatarColor = ref('#888')
+ const isLoading = ref(false)
+
+ async function resolve(id: string | null) {
+ if (!id) {
+ displayName.value = '...'
+ avatarColor.value = '#888'
+ isLoading.value = false
+ return
+ }
+
+ if (id === auth.user?.userId) {
+ displayName.value = 'Me'
+ avatarColor.value = avatarColorFromString(auth.user.username || 'Me')
+ isLoading.value = false
+ return
+ }
+
+ isLoading.value = true
+ try {
+ const user = await fetchUser(id)
+ displayName.value = user.username || 'Unknown'
+ avatarColor.value = avatarColorFromString(user.username || id)
+ } catch {
+ displayName.value = 'Unknown'
+ avatarColor.value = '#888'
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ watch(ownerId, resolve, { immediate: true })
+
+ return { displayName, avatarColor, isLoading }
+}
diff --git a/front/src/services/authService.ts b/front/src/services/authService.ts
index 58969bd..fd74c70 100644
--- a/front/src/services/authService.ts
+++ b/front/src/services/authService.ts
@@ -34,8 +34,9 @@ export const authService = {
async getMe() : Promise {
const raw = await api.get('/api/User/me')
// backend User may have fields like userName / UserName and email / Email
+ const userId = raw?.userId ?? ''
const username = raw?.userName ?? raw?.UserName ?? raw?.username ?? raw?.name ?? ''
const email = raw?.email ?? raw?.Email ?? ''
- return { username, email }
+ return { userId, username, email }
},
}
diff --git a/front/src/services/userService.ts b/front/src/services/userService.ts
new file mode 100644
index 0000000..cdbc2f3
--- /dev/null
+++ b/front/src/services/userService.ts
@@ -0,0 +1,34 @@
+import type { User } from '@/types'
+import { api } from './api'
+
+const userCache = new Map()
+const pendingRequests = new Map>()
+
+export async function fetchUser(userId: string): Promise {
+ const cached = userCache.get(userId)
+ if (cached) return cached
+
+ const pending = pendingRequests.get(userId)
+ if (pending) return pending
+
+ const promise = api.get(`/api/User?userId=${userId}`).then((raw) => {
+ const id = raw?.userId ?? raw?.UserId ?? raw?.id ?? raw?.Id ?? userId
+ const username = raw?.userName ?? raw?.UserName ?? raw?.username ?? raw?.name ?? ''
+ const email = raw?.email ?? raw?.Email ?? ''
+ const user: User = { userId: id, username, email }
+ userCache.set(userId, user)
+ pendingRequests.delete(userId)
+ return user
+ }).catch((err) => {
+ pendingRequests.delete(userId)
+ throw err
+ })
+
+ pendingRequests.set(userId, promise)
+ return promise
+}
+
+export function clearUserCache() {
+ userCache.clear()
+ pendingRequests.clear()
+}
diff --git a/front/src/services/whiteboardHubService.ts b/front/src/services/whiteboardHubService.ts
index 92f65c0..0ef37f8 100644
--- a/front/src/services/whiteboardHubService.ts
+++ b/front/src/services/whiteboardHubService.ts
@@ -1,5 +1,5 @@
import { SignalRService } from '@/services/signalr.ts'
-import type { Rectangle, Whiteboard } from '@/types/whiteboard.ts'
+import type { Arrow, Line, MoveShapeCommand, Rectangle, TextShape, Whiteboard } from '@/types/whiteboard.ts'
const client = new SignalRService(`/hubs/whiteboard`)
@@ -24,6 +24,18 @@ export const whiteboardHubService = {
await client.invoke('AddRectangle', rectangle)
},
+ async addArrow(arrow: Arrow) {
+ await client.invoke('AddArrow', arrow)
+ },
+
+ async addLine(line: Line) {
+ await client.invoke('AddLine', line)
+ },
+
+ async addTextShape(textShape: TextShape) {
+ await client.invoke('AddTextShape', textShape)
+ },
+
onInitWhiteboard(callback: (whiteboard: Whiteboard) => void) {
client.on('InitWhiteboard', callback)
},
@@ -32,6 +44,30 @@ export const whiteboardHubService = {
client.on('AddedRectangle', callback)
},
+ onAddedArrow(callback: (arrow: Arrow) => void) {
+ client.on('AddedArrow', callback)
+ },
+
+ onAddedLine(callback: (line: Line) => void) {
+ client.on('AddedLine', callback)
+ },
+
+ onAddedTextShape(callback: (textShape: TextShape) => void) {
+ client.on('AddedTextShape', callback)
+ },
+
+ async moveShape(command: MoveShapeCommand) {
+ await client.invoke('MoveShape', command)
+ },
+
+ async placeShape(command: MoveShapeCommand) {
+ await client.invoke('PlaceShape', command)
+ },
+
+ onMovedShape(callback: (command: MoveShapeCommand) => void) {
+ client.on('MovedShape', callback)
+ },
+
onJoined(callback: (userId: string) => void) {
client.on('Joined', callback)
},
@@ -43,6 +79,10 @@ export const whiteboardHubService = {
offAll() {
client.off('InitWhiteboard')
client.off('AddedRectangle')
+ client.off('AddedArrow')
+ client.off('AddedLine')
+ client.off('AddedTextShape')
+ client.off('MovedShape')
client.off('Joined')
client.off('Leaved')
},
diff --git a/front/src/stores/whiteboard.ts b/front/src/stores/whiteboard.ts
index 1a3881a..758f350 100644
--- a/front/src/stores/whiteboard.ts
+++ b/front/src/stores/whiteboard.ts
@@ -1,15 +1,32 @@
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
-import type { Rectangle, ShapeTool, Whiteboard } from '@/types/whiteboard.ts'
+import type { Arrow, Line, Rectangle, Shape, ShapeTool, ShapeType, TextShape, Whiteboard } from '@/types/whiteboard.ts'
import { whiteboardHubService } from '@/services/whiteboardHubService.ts'
export const useWhiteboardStore = defineStore('whiteboard', () => {
const whiteboard = ref(null)
- const selectedTool = ref('rectangle')
+ const selectedTool = ref('hand')
const isConnected = ref(false)
const isLoading = ref(false)
const error = ref(null)
+ const selectedShapeId = ref(null)
+ const selectedShapeType = ref(null)
+ const toolColor = ref('#4f9dff')
+ const toolThickness = ref(2)
+ const toolTextSize = ref(24)
+
+ const selectedShape = computed(() => {
+ if (!selectedShapeId.value || !selectedShapeType.value || !whiteboard.value) return null
+ switch (selectedShapeType.value) {
+ case 'rectangle': return whiteboard.value.rectangles.find(s => s.id === selectedShapeId.value) ?? null
+ case 'arrow': return whiteboard.value.arrows.find(s => s.id === selectedShapeId.value) ?? null
+ case 'line': return whiteboard.value.lines.find(s => s.id === selectedShapeId.value) ?? null
+ case 'textShape': return whiteboard.value.textShapes.find(s => s.id === selectedShapeId.value) ?? null
+ default: return null
+ }
+ })
+
async function joinWhiteboard(id: string) {
isLoading.value = true
error.value = null
@@ -27,6 +44,22 @@ export const useWhiteboardStore = defineStore('whiteboard', () => {
whiteboard.value?.rectangles.push(rectangle)
})
+ whiteboardHubService.onAddedArrow((arrow) => {
+ whiteboard.value?.arrows.push(arrow)
+ })
+
+ whiteboardHubService.onAddedLine((line) => {
+ whiteboard.value?.lines.push(line)
+ })
+
+ whiteboardHubService.onAddedTextShape((textShape) => {
+ whiteboard.value?.textShapes.push(textShape)
+ })
+
+ whiteboardHubService.onMovedShape((command) => {
+ applyMoveShape(command.shapeId, command.newPositionX, command.newPositionY)
+ })
+
whiteboardHubService.onJoined((userId) => {
console.log('User joined:', userId)
})
@@ -56,7 +89,8 @@ export const useWhiteboardStore = defineStore('whiteboard', () => {
whiteboard.value = null
isConnected.value = false
- selectedTool.value = 'rectangle'
+ selectedTool.value = 'hand'
+ deselectShape()
error.value = null
}
@@ -70,19 +104,97 @@ export const useWhiteboardStore = defineStore('whiteboard', () => {
}
}
+ async function addArrow(arrow: Arrow) {
+ whiteboard.value?.arrows.push(arrow)
+
+ try {
+ await whiteboardHubService.addArrow(arrow)
+ } catch (e: any) {
+ console.error('Failed to send arrow', e)
+ }
+ }
+
+ async function addLine(line: Line) {
+ whiteboard.value?.lines.push(line)
+
+ try {
+ await whiteboardHubService.addLine(line)
+ } catch (e: any) {
+ console.error('Failed to send line', e)
+ }
+ }
+
+ async function addTextShape(textShape: TextShape) {
+ whiteboard.value?.textShapes.push(textShape)
+
+ try {
+ await whiteboardHubService.addTextShape(textShape)
+ } catch (e: any) {
+ console.error('Failed to send text shape', e)
+ }
+ }
+
function selectTool(tool: ShapeTool) {
selectedTool.value = tool
+ deselectShape()
}
+ function selectShape(id: string, type: ShapeType) {
+ selectedShapeId.value = id
+ selectedShapeType.value = type
+ }
+
+ function deselectShape() {
+ selectedShapeId.value = null
+ selectedShapeType.value = null
+ }
+
+ function applyMoveShape(shapeId: string, newPosX: number, newPosY: number) {
+ const wb = whiteboard.value
+ if (!wb) return
+ const all: Shape[] = [...wb.rectangles, ...wb.arrows, ...wb.lines, ...wb.textShapes]
+ const shape = all.find(s => s.id === shapeId)
+ if (!shape) return
+
+ const dx = newPosX - shape.position.x
+ const dy = newPosY - shape.position.y
+ shape.position.x = newPosX
+ shape.position.y = newPosY
+
+ if ('endPosition' in shape) {
+ (shape as any).endPosition.x += dx
+ ;(shape as any).endPosition.y += dy
+ }
+ }
+
+ function setToolColor(color: string) { toolColor.value = color }
+ function setToolThickness(thickness: number) { toolThickness.value = thickness }
+ function setToolTextSize(size: number) { toolTextSize.value = size }
+
return {
whiteboard,
selectedTool,
isConnected,
isLoading,
error,
+ selectedShapeId,
+ selectedShapeType,
+ selectedShape,
+ toolColor,
+ toolThickness,
+ toolTextSize,
joinWhiteboard,
leaveWhiteboard,
addRectangle,
+ addArrow,
+ addLine,
+ addTextShape,
selectTool,
+ selectShape,
+ deselectShape,
+ applyMoveShape,
+ setToolColor,
+ setToolThickness,
+ setToolTextSize,
}
})
diff --git a/front/src/types/index.ts b/front/src/types/index.ts
index 0fa8e21..649359f 100644
--- a/front/src/types/index.ts
+++ b/front/src/types/index.ts
@@ -1,6 +1,7 @@
import {type WhiteboardJoinPolicy, WhiteboardState} from "@/enums";
export interface User {
+ userId: string
username: string
email: string
}
diff --git a/front/src/types/whiteboard.ts b/front/src/types/whiteboard.ts
index 85818ab..5cb135d 100644
--- a/front/src/types/whiteboard.ts
+++ b/front/src/types/whiteboard.ts
@@ -39,4 +39,11 @@ export interface Whiteboard {
textShapes: TextShape[]
}
-export type ShapeTool = 'rectangle' | 'arrow' | 'line' | 'text'
+export interface MoveShapeCommand {
+ shapeId: string
+ newPositionX: number
+ newPositionY: number
+}
+
+export type ShapeTool = 'hand' | 'rectangle' | 'arrow' | 'line' | 'text'
+export type ShapeType = 'rectangle' | 'arrow' | 'line' | 'textShape'