implement

This commit is contained in:
2026-02-17 00:48:28 +01:00
parent 0119c7a737
commit 5c7909034f
57 changed files with 1676 additions and 114 deletions

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useShapeOwner } from '@/composables/useShapeOwner'
const props = defineProps<{
ownerId: string
anchorX: number
anchorY: number
}>()
const ownerIdRef = toRef(props, 'ownerId')
const { displayName, avatarColor } = useShapeOwner(ownerIdRef)
const firstLetter = computed(() => displayName.value.charAt(0).toUpperCase())
const labelY = computed(() => props.anchorY - 24)
</script>
<template>
<g :transform="`translate(${anchorX}, ${labelY})`">
<circle r="8" :fill="avatarColor" cx="0" cy="0" />
<text
x="0"
y="0"
text-anchor="middle"
dominant-baseline="central"
fill="#fff"
font-size="9"
font-weight="700"
>
{{ firstLetter }}
</text>
<text
x="14"
y="0"
dominant-baseline="central"
fill="rgba(255,255,255,0.85)"
font-size="12"
>
{{ displayName }}
</text>
</g>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useShapeOwner } from '@/composables/useShapeOwner'
const props = defineProps<{
ownerId: string
cursorX: number
cursorY: number
}>()
const ownerIdRef = toRef(props, 'ownerId')
const { displayName, avatarColor } = useShapeOwner(ownerIdRef)
const firstLetter = computed(() => displayName.value.charAt(0).toUpperCase())
</script>
<template>
<div
class="shape-owner-tooltip"
:style="{ left: cursorX + 12 + 'px', top: cursorY - 8 + 'px' }"
>
<span class="avatar" :style="{ backgroundColor: avatarColor }">
{{ firstLetter }}
</span>
<span class="name">{{ displayName }}</span>
</div>
</template>
<style scoped>
.shape-owner-tooltip {
position: fixed;
pointer-events: none;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(13, 13, 26, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
white-space: nowrap;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.name {
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
}
</style>

View File

@@ -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>

View File

@@ -1,44 +1,133 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
import type { ShapeTool } from '@/types/whiteboard.ts'
import type { ShapeTool, Arrow, Line, Rectangle, TextShape } 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 },
{ name: 'hand', label: 'Select', icon: '\u270B', enabled: true },
{ name: 'rectangle', label: 'Rectangle', icon: '\u25AD', enabled: true },
{ name: 'arrow', label: 'Arrow', icon: '\u2192', enabled: true },
{ name: 'line', label: 'Line', icon: '\u2571', enabled: true },
{ name: 'text', label: 'Text', icon: 'T', enabled: true },
]
const colors = ['#4f9dff', '#ff4f4f', '#4fff4f', '#ffff4f', '#ff4fff', '#ffffff', '#ff9f4f', '#4fffff']
const isReadOnly = computed(() => store.selectedTool === 'hand' && !!store.selectedShape)
const showProperties = computed(() => {
if (['rectangle', 'arrow', 'line', 'text'].includes(store.selectedTool)) return true
if (store.selectedTool === 'hand' && store.selectedShape) return true
return false
})
const showThickness = computed(() => {
if (['rectangle', 'arrow', 'line'].includes(store.selectedTool)) return true
if (isReadOnly.value && store.selectedShapeType && ['rectangle', 'arrow', 'line'].includes(store.selectedShapeType)) return true
return false
})
const showTextSize = computed(() => {
if (store.selectedTool === 'text') return true
if (isReadOnly.value && store.selectedShapeType === 'textShape') return true
return false
})
const displayColor = computed(() => {
if (isReadOnly.value && store.selectedShape) return store.selectedShape.color
return store.toolColor
})
const displayThickness = computed(() => {
if (isReadOnly.value && store.selectedShape) {
if (store.selectedShapeType === 'rectangle') return (store.selectedShape as Rectangle).borderThickness
return (store.selectedShape as Arrow | Line).thickness
}
return store.toolThickness
})
const displayTextSize = computed(() => {
if (isReadOnly.value && store.selectedShape) return (store.selectedShape as TextShape).textSize
return store.toolTextSize
})
</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">
<div class="toolbar">
<div class="tools-grid">
<button
class="btn btn-sm btn-outline-danger"
v-for="tool in tools"
:key="tool.name"
class="tool-btn"
:class="{ active: store.selectedTool === tool.name, disabled: !tool.enabled }"
:disabled="!tool.enabled"
:title="tool.enabled ? tool.label : `${tool.label} (coming soon)`"
@click="tool.enabled && store.selectTool(tool.name)"
>
{{ tool.icon }}
</button>
</div>
<div v-if="showProperties" class="properties-panel">
<div>
<div class="property-label">Color</div>
<div v-if="isReadOnly" class="color-swatches">
<div
class="color-swatch"
:style="{ backgroundColor: displayColor }"
/>
</div>
<div v-else class="color-swatches">
<div
v-for="c in colors"
:key="c"
class="color-swatch"
:class="{ active: store.toolColor === c }"
:style="{ backgroundColor: c }"
@click="store.setToolColor(c)"
/>
</div>
</div>
<div v-if="showThickness">
<div class="property-label">Thickness: {{ displayThickness }}</div>
<input
v-if="!isReadOnly"
type="range"
class="property-range"
min="1"
max="10"
step="1"
:value="store.toolThickness"
@input="store.setToolThickness(Number(($event.target as HTMLInputElement).value))"
/>
</div>
<div v-if="showTextSize">
<div class="property-label">Text Size: {{ displayTextSize }}</div>
<input
v-if="!isReadOnly"
type="range"
class="property-range"
min="12"
max="72"
step="2"
:value="store.toolTextSize"
@input="store.setToolTextSize(Number(($event.target as HTMLInputElement).value))"
/>
</div>
</div>
<div class="toolbar-footer">
<button
class="btn btn-sm btn-outline-danger leave-btn"
title="Leave whiteboard"
style="width: 40px; height: 40px"
@click="emit('leave')"
>
Leave
</button>
</div>
</div>
@@ -46,9 +135,96 @@ const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[
<style scoped>
.toolbar {
width: 56px;
width: 180px;
background-color: #0d0d1a;
border-right: 1px solid #2a2a3e;
height: 100%;
display: flex;
flex-direction: column;
padding: 8px;
}
.tools-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tool-btn {
width: 32px;
height: 32px;
font-size: 1rem;
border: 1px solid #2a2a3e;
border-radius: 6px;
background: transparent;
color: #8a8a9e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s, color 0.15s;
}
.tool-btn:hover {
background-color: #1a1a2e;
color: #fff;
}
.tool-btn.active {
background-color: #4f9dff;
color: #fff;
border-color: #4f9dff;
}
.tool-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.properties-panel {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.property-label {
font-size: 0.75rem;
color: #8a8a9e;
margin-bottom: 4px;
}
.color-swatches {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.color-swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: border-color 0.15s;
}
.color-swatch.active {
border-color: white;
}
.property-range {
width: 100%;
accent-color: #4f9dff;
}
.toolbar-footer {
margin-top: auto;
padding-top: 8px;
}
.leave-btn {
width: 100%;
height: 36px;
}
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { Arrow } from '@/types/whiteboard.ts'
const props = withDefaults(defineProps<{ arrow: Arrow; isSelected?: boolean }>(), {
isSelected: false,
})
const markerId = `arrowhead-${props.arrow.id}`
</script>
<template>
<g :data-shape-id="props.arrow.id" data-shape-type="arrow">
<defs>
<marker
:id="markerId"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" :fill="props.arrow.color" />
</marker>
</defs>
<line
:x1="props.arrow.position.x"
:y1="props.arrow.position.y"
:x2="props.arrow.endPosition.x"
:y2="props.arrow.endPosition.y"
stroke="transparent"
stroke-width="12"
/>
<line
:x1="props.arrow.position.x"
:y1="props.arrow.position.y"
:x2="props.arrow.endPosition.x"
:y2="props.arrow.endPosition.y"
:stroke="props.arrow.color"
:stroke-width="props.arrow.thickness"
:marker-end="`url(#${markerId})`"
/>
<template v-if="isSelected">
<circle :cx="props.arrow.position.x" :cy="props.arrow.position.y" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
<circle :cx="props.arrow.endPosition.x" :cy="props.arrow.endPosition.y" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
</template>
</g>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
const props = defineProps<{
x1: number
y1: number
x2: number
y2: number
color: string
thickness: number
}>()
</script>
<template>
<defs>
<marker
id="draft-arrowhead"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" :fill="props.color" opacity="0.7" />
</marker>
</defs>
<line
:x1="props.x1"
:y1="props.y1"
:x2="props.x2"
:y2="props.y2"
:stroke="props.color"
:stroke-width="props.thickness"
stroke-dasharray="6 3"
opacity="0.7"
marker-end="url(#draft-arrowhead)"
/>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const props = defineProps<{
x1: number
y1: number
x2: number
y2: number
color: string
thickness: number
}>()
</script>
<template>
<line
:x1="props.x1"
:y1="props.y1"
:x2="props.x2"
:y2="props.y2"
:stroke="props.color"
:stroke-width="props.thickness"
stroke-dasharray="6 3"
opacity="0.7"
/>
</template>

View File

@@ -4,6 +4,8 @@ const props = defineProps<{
y: number
width: number
height: number
color: string
thickness: number
}>()
</script>
@@ -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"

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const props = defineProps<{
x: number
y: number
textValue: string
textSize: number
color: string
}>()
</script>
<template>
<text
:x="props.x"
:y="props.y"
:font-size="props.textSize"
:fill="props.color"
opacity="0.7"
font-family="Arial, sans-serif"
dominant-baseline="hanging"
>
{{ props.textValue }}
</text>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { Line } from '@/types/whiteboard.ts'
const props = withDefaults(defineProps<{ line: Line; isSelected?: boolean }>(), {
isSelected: false,
})
</script>
<template>
<g :data-shape-id="props.line.id" data-shape-type="line">
<line
:x1="props.line.position.x"
:y1="props.line.position.y"
:x2="props.line.endPosition.x"
:y2="props.line.endPosition.y"
stroke="transparent"
stroke-width="12"
/>
<line
:x1="props.line.position.x"
:y1="props.line.position.y"
:x2="props.line.endPosition.x"
:y2="props.line.endPosition.y"
:stroke="props.line.color"
:stroke-width="props.line.thickness"
fill="none"
/>
<template v-if="isSelected">
<circle :cx="props.line.position.x" :cy="props.line.position.y" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
<circle :cx="props.line.endPosition.x" :cy="props.line.endPosition.y" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
</template>
</g>
</template>

View File

@@ -1,17 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Rectangle } from '@/types/whiteboard.ts'
const props = defineProps<{ rectangle: Rectangle }>()
const props = withDefaults(defineProps<{ rectangle: Rectangle; isSelected?: boolean }>(), {
isSelected: false,
})
const x = computed(() => Math.min(props.rectangle.position.x, props.rectangle.endPosition.x))
const y = computed(() => Math.min(props.rectangle.position.y, props.rectangle.endPosition.y))
const w = computed(() => Math.abs(props.rectangle.endPosition.x - props.rectangle.position.x))
const h = computed(() => Math.abs(props.rectangle.endPosition.y - props.rectangle.position.y))
</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"
/>
<g :data-shape-id="props.rectangle.id" data-shape-type="rectangle">
<rect
:x="x"
:y="y"
:width="w"
:height="h"
:stroke="props.rectangle.color"
:stroke-width="props.rectangle.borderThickness"
fill="none"
/>
<template v-if="isSelected">
<rect
:x="x"
:y="y"
:width="w"
:height="h"
stroke="#4f9dff"
stroke-width="1"
stroke-dasharray="4 2"
fill="none"
/>
<circle :cx="x" :cy="y" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
<circle :cx="x + w" :cy="y" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
<circle :cx="x" :cy="y + h" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
<circle :cx="x + w" :cy="y + h" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
</template>
</g>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, watch, onMounted, nextTick } from 'vue'
import type { TextShape } from '@/types/whiteboard.ts'
const props = withDefaults(defineProps<{ textShape: TextShape; isSelected?: boolean }>(), {
isSelected: false,
})
const textEl = ref<SVGTextElement>()
const bbox = ref<{ x: number; y: number; width: number; height: number } | null>(null)
function updateBBox() {
if (textEl.value) {
const b = textEl.value.getBBox()
bbox.value = { x: b.x, y: b.y, width: b.width, height: b.height }
}
}
onMounted(updateBBox)
watch(() => props.isSelected, (val) => {
if (val) nextTick(updateBBox)
}, { flush: 'post' })
</script>
<template>
<g :data-shape-id="props.textShape.id" data-shape-type="textShape">
<text
ref="textEl"
:x="props.textShape.position.x"
:y="props.textShape.position.y"
:fill="props.textShape.color"
:font-size="props.textShape.textSize"
font-family="Arial, sans-serif"
dominant-baseline="hanging"
>
{{ props.textShape.textValue }}
</text>
<template v-if="isSelected && bbox">
<rect
:x="bbox.x - 4"
:y="bbox.y - 4"
:width="bbox.width + 8"
:height="bbox.height + 8"
stroke="#4f9dff"
stroke-width="1"
stroke-dasharray="4 2"
fill="none"
/>
<circle :cx="bbox.x - 4" :cy="bbox.y - 4" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
<circle :cx="bbox.x + bbox.width + 4" :cy="bbox.y - 4" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
<circle :cx="bbox.x - 4" :cy="bbox.y + bbox.height + 4" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
<circle :cx="bbox.x + bbox.width + 4" :cy="bbox.y + bbox.height + 4" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
</template>
</g>
</template>