Merge pull request #40 from StewKI/feature-more-tools

Tools improvements & moving shapes
This commit is contained in:
Andrija Stevanović
2026-02-17 01:25:53 +01:00
committed by GitHub
60 changed files with 1683 additions and 118 deletions

View File

@@ -20,6 +20,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Application\Models\Shape\Command\DeleteShape\" />
<Folder Include="Domain\Models\WhiteboardMembership\Validation\" />
<Folder Include="Infrastructure\Persistence\Db\Migrations\" />
</ItemGroup>

View File

@@ -0,0 +1,6 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Models.Shape.Command.CreateArrow;
namespace AipsCore.Application.Common.Message.AddArrow;
public record AddArrowMessage(CreateArrowCommand Command) : IMessage;

View File

@@ -0,0 +1,19 @@
using AipsCore.Application.Abstract;
using AipsCore.Application.Abstract.MessageBroking;
namespace AipsCore.Application.Common.Message.AddArrow;
public class AddArrowMessageHandler : IMessageHandler<AddArrowMessage>
{
private readonly IDispatcher _dispatcher;
public AddArrowMessageHandler(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
public async Task Handle(AddArrowMessage message, CancellationToken cancellationToken)
{
await _dispatcher.Execute(message.Command, cancellationToken);
}
}

View File

@@ -0,0 +1,6 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Models.Shape.Command.CreateLine;
namespace AipsCore.Application.Common.Message.AddLine;
public record AddLineMessage(CreateLineCommand Command) : IMessage;

View File

@@ -0,0 +1,20 @@
using AipsCore.Application.Abstract;
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Models.Shape.Command.CreateLine;
namespace AipsCore.Application.Common.Message.AddLine;
public class AddLineMessageHandler : IMessageHandler<AddLineMessage>
{
private readonly IDispatcher _dispatcher;
public AddLineMessageHandler(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
public async Task Handle(AddLineMessage message, CancellationToken cancellationToken)
{
await _dispatcher.Execute(message.Command, cancellationToken);
}
}

View File

@@ -0,0 +1,6 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Models.Shape.Command.CreateTextShape;
namespace AipsCore.Application.Common.Message.AddTextShape;
public record AddTextShapeMessage(CreateTextShapeCommand Command) : IMessage;

View File

@@ -0,0 +1,19 @@
using AipsCore.Application.Abstract;
using AipsCore.Application.Abstract.MessageBroking;
namespace AipsCore.Application.Common.Message.AddTextShape;
public class AddTextShapeMessageHandler : IMessageHandler<AddTextShapeMessage>
{
private readonly IDispatcher _dispatcher;
public AddTextShapeMessageHandler(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
public async Task Handle(AddTextShapeMessage message, CancellationToken cancellationToken)
{
await _dispatcher.Execute(message.Command, cancellationToken);
}
}

View File

@@ -0,0 +1,6 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Models.Shape.Command.MoveShape;
namespace AipsCore.Application.Common.Message.MoveShape;
public record MoveShapeMessage(MoveShapeCommand Command) : IMessage;

View File

@@ -0,0 +1,19 @@
using AipsCore.Application.Abstract;
using AipsCore.Application.Abstract.MessageBroking;
namespace AipsCore.Application.Common.Message.MoveShape;
public class MoveShapeMessageHandler : IMessageHandler<MoveShapeMessage>
{
private readonly IDispatcher _dispatcher;
public MoveShapeMessageHandler(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
public async Task Handle(MoveShapeMessage message, CancellationToken cancellationToken)
{
await _dispatcher.Execute(message.Command, cancellationToken);
}
}

View File

@@ -4,6 +4,7 @@ using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.CreateArrow;
public record CreateArrowCommand(
string Id,
string WhiteboardId,
string AuthorId,
int PositionX,
@@ -11,4 +12,4 @@ public record CreateArrowCommand(
string Color,
int EndPositionX,
int EndPositionY,
int Thickness) : ICommand<ShapeId>;
int Thickness) : ICommand;

View File

@@ -5,7 +5,7 @@ using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.CreateArrow;
public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand, ShapeId>
public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand>
{
private readonly IShapeRepository _shapeRepository;
private readonly IUnitOfWork _unitOfWork;
@@ -16,9 +16,10 @@ public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand, Sha
_unitOfWork = unitOfWork;
}
public async Task<ShapeId> Handle(CreateArrowCommand command, CancellationToken cancellationToken = default)
public async Task Handle(CreateArrowCommand command, CancellationToken cancellationToken = default)
{
var arrow = Domain.Models.Shape.Sub.Arrow.Arrow.Create(
command.Id,
command.WhiteboardId,
command.AuthorId,
command.PositionX, command.PositionY,
@@ -29,7 +30,5 @@ public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand, Sha
await _shapeRepository.SaveAsync(arrow, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return arrow.Id;
}
}

View File

@@ -4,6 +4,7 @@ using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.CreateLine;
public record CreateLineCommand(
string Id,
string WhiteboardId,
string AuthorId,
int PositionX,
@@ -11,4 +12,4 @@ public record CreateLineCommand(
string Color,
int EndPositionX,
int EndPositionY,
int Thickness) : ICommand<ShapeId>;
int Thickness) : ICommand;

View File

@@ -5,7 +5,7 @@ using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.CreateLine;
public class CreateLineCommandHandler : ICommandHandler<CreateLineCommand, ShapeId>
public class CreateLineCommandHandler : ICommandHandler<CreateLineCommand>
{
private readonly IShapeRepository _shapeRepository;
private readonly IUnitOfWork _unitOfWork;
@@ -16,9 +16,10 @@ public class CreateLineCommandHandler : ICommandHandler<CreateLineCommand, Shape
_unitOfWork = unitOfWork;
}
public async Task<ShapeId> Handle(CreateLineCommand command, CancellationToken cancellationToken = default)
public async Task Handle(CreateLineCommand command, CancellationToken cancellationToken = default)
{
var line = Domain.Models.Shape.Sub.Line.Line.Create(
command.Id,
command.WhiteboardId,
command.AuthorId,
command.PositionX,
@@ -30,7 +31,5 @@ public class CreateLineCommandHandler : ICommandHandler<CreateLineCommand, Shape
await _shapeRepository.SaveAsync(line, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return line.Id;
}
}

View File

@@ -4,10 +4,11 @@ using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.CreateTextShape;
public record CreateTextShapeCommand(
string Id,
string WhiteboardId,
string AuthorId,
int PositionX,
int PositionY,
string Color,
string Text,
int TextSize) : ICommand<ShapeId>;
int TextSize) : ICommand;

View File

@@ -6,7 +6,7 @@ using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.CreateTextShape;
public class CreateTextShapeCommandHandler : ICommandHandler<CreateTextShapeCommand, ShapeId>
public class CreateTextShapeCommandHandler : ICommandHandler<CreateTextShapeCommand>
{
private readonly IShapeRepository _shapeRepository;
private readonly IUnitOfWork _unitOfWork;
@@ -17,9 +17,10 @@ public class CreateTextShapeCommandHandler : ICommandHandler<CreateTextShapeComm
_unitOfWork = unitOfWork;
}
public async Task<ShapeId> Handle(CreateTextShapeCommand command, CancellationToken cancellationToken = default)
public async Task Handle(CreateTextShapeCommand command, CancellationToken cancellationToken = default)
{
var textShape = TextShape.Create(
command.Id,
command.WhiteboardId,
command.AuthorId,
command.PositionX,
@@ -30,7 +31,5 @@ public class CreateTextShapeCommandHandler : ICommandHandler<CreateTextShapeComm
await _shapeRepository.SaveAsync(textShape, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return textShape.Id;
}
}

View File

@@ -0,0 +1,5 @@
using AipsCore.Application.Abstract.Command;
namespace AipsCore.Application.Models.Shape.Command.MoveShape;
public record MoveShapeCommand(string ShapeId, int NewPositionX, int NewPositionY) : ICommand;

View File

@@ -0,0 +1,36 @@
using AipsCore.Application.Abstract.Command;
using AipsCore.Domain.Abstract;
using AipsCore.Domain.Common.Validation;
using AipsCore.Domain.Models.Shape.External;
using AipsCore.Domain.Models.Shape.Validation;
using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.MoveShape;
public class MoveShapeCommandHandler : ICommandHandler<MoveShapeCommand>
{
private readonly IShapeRepository _shapeRepository;
private readonly IUnitOfWork _unitOfWork;
public MoveShapeCommandHandler(IShapeRepository shapeRepository, IUnitOfWork unitOfWork)
{
_shapeRepository = shapeRepository;
_unitOfWork = unitOfWork;
}
public async Task Handle(MoveShapeCommand command, CancellationToken cancellationToken = default)
{
var id = new ShapeId(command.ShapeId);
var shape = await _shapeRepository.GetByIdAsync(id, cancellationToken);
if (shape == null)
{
throw new ValidationException(ShapeErrors.NotFound(id));
}
shape.Move(command.NewPositionX, command.NewPositionY);
await _shapeRepository.SaveAsync(shape, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -2,4 +2,4 @@ using AipsCore.Application.Abstract.Query;
namespace AipsCore.Application.Models.User.Query.GetUser;
public record GetUserQuery(string UserId) : IQuery<Infrastructure.Persistence.User.User>;
public record GetUserQuery(string UserId) : IQuery<GetUserQueryDto>;

View File

@@ -0,0 +1,3 @@
namespace AipsCore.Application.Models.User.Query.GetUser;
public record GetUserQueryDto(string Id, string Email, string UserName);

View File

@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace AipsCore.Application.Models.User.Query.GetUser;
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, Infrastructure.Persistence.User.User>
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, GetUserQueryDto>
{
private readonly AipsDbContext _context;
@@ -16,7 +16,7 @@ public class GetUserQueryHandler : IQueryHandler<GetUserQuery, Infrastructure.Pe
_context = context;
}
public async Task<Infrastructure.Persistence.User.User> Handle(GetUserQuery query, CancellationToken cancellationToken = default)
public async Task<GetUserQueryDto> Handle(GetUserQuery query, CancellationToken cancellationToken = default)
{
var result = await _context.Users
.Where(u => u.Id.ToString() == query.UserId)
@@ -27,6 +27,6 @@ public class GetUserQueryHandler : IQueryHandler<GetUserQuery, Infrastructure.Pe
throw new ValidationException(UserErrors.NotFound(new UserId(query.UserId)));
}
return result;
return new GetUserQueryDto(result.Id.ToString(), result.Email!, result.UserName!);
}
}

View File

@@ -0,0 +1,13 @@
using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Domain.Models.Shape;
public partial class Shape
{
public virtual void Move(int newPositionX, int newPositionY)
{
var newPosition = new Position(newPositionX, newPositionY);
this.Position = newPosition;
}
}

View File

@@ -7,7 +7,7 @@ using AipsCore.Domain.Models.Whiteboard.ValueObjects;
namespace AipsCore.Domain.Models.Shape;
public abstract class Shape : DomainModel<ShapeId>
public abstract partial class Shape : DomainModel<ShapeId>
{
public WhiteboardId WhiteboardId { get; private set; }

View File

@@ -0,0 +1,11 @@
namespace AipsCore.Domain.Models.Shape.Sub.Arrow;
public partial class Arrow
{
public override void Move(int newPositionX, int newPositionY)
{
EndPosition.X += newPositionX - Position.X;
EndPosition.Y += newPositionY - Position.Y;
base.Move(newPositionX, newPositionY);
}
}

View File

@@ -6,7 +6,7 @@ using AipsCore.Domain.Models.Whiteboard.ValueObjects;
namespace AipsCore.Domain.Models.Shape.Sub.Arrow;
public class Arrow : Shape
public partial class Arrow : Shape
{
public Position EndPosition { get; private set; }
public Thickness Thickness { get; private set; }

View File

@@ -0,0 +1,11 @@
namespace AipsCore.Domain.Models.Shape.Sub.Line;
public partial class Line
{
public override void Move(int newPositionX, int newPositionY)
{
EndPosition.X += newPositionX - Position.X;
EndPosition.Y += newPositionY - Position.Y;
base.Move(newPositionX, newPositionY);
}
}

View File

@@ -6,7 +6,7 @@ using AipsCore.Domain.Models.Whiteboard.ValueObjects;
namespace AipsCore.Domain.Models.Shape.Sub.Line;
public class Line : Shape
public partial class Line : Shape
{
public Position EndPosition { get; private set; }
public Thickness Thickness { get; private set; }

View File

@@ -0,0 +1,11 @@
namespace AipsCore.Domain.Models.Shape.Sub.Rectangle;
public partial class Rectangle
{
public override void Move(int newPositionX, int newPositionY)
{
EndPosition.X += newPositionX - Position.X;
EndPosition.Y += newPositionY - Position.Y;
base.Move(newPositionX, newPositionY);
}
}

View File

@@ -6,11 +6,11 @@ using AipsCore.Domain.Models.Whiteboard.ValueObjects;
namespace AipsCore.Domain.Models.Shape.Sub.Rectangle;
public class Rectangle : Shape
public partial class Rectangle : Shape
{
public override ShapeType ShapeType => ShapeType.Rectangle;
public Position EndPosition { get; }
public Position EndPosition { get; set; }
public Thickness BorderThickness { get; }

View File

@@ -0,0 +1,9 @@
using AipsCore.Domain.Abstract.Validation;
using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Domain.Models.Shape.Validation;
public class ShapeErrors : AbstractErrors<Shape, ShapeId>
{
}

View File

@@ -5,8 +5,8 @@ namespace AipsCore.Domain.Models.Shape.ValueObjects;
public record Position : AbstractValueObject
{
public int X { get; }
public int Y { get; }
public int X { get; set; }
public int Y { get; set; }
public Position(int x, int y)
{
@@ -20,4 +20,14 @@ public record Position : AbstractValueObject
];
}
public static Position operator -(Position position, Position otherPosition)
{
return new Position(position.X - otherPosition.X, position.Y - otherPosition.Y);
}
public static Position operator +(Position position, Position otherPosition)
{
return new Position(position.X + otherPosition.X, position.Y + otherPosition.Y);
}
};

View File

@@ -25,6 +25,7 @@ public static partial class ShapeMappers
return Rectangle.Create(
shape.Id.ToString(),
shape.WhiteboardId.ToString(),
shape.AuthorId.ToString(),
shape.PositionX, shape.PositionY,
shape.Color,
shape.EndPositionX!.Value, shape.EndPositionY!.Value,

View File

@@ -1,6 +1,9 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Common.Message.MoveShape;
using AipsCore.Application.Models.Shape.Command.MoveShape;
using AipsRT.Model.Whiteboard;
using AipsRT.Model.Whiteboard.Shapes;
using AipsRT.Model.Whiteboard.Structs;
using AipsRT.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
@@ -41,17 +44,89 @@ public class WhiteboardHub : Hub
.SendAsync("Leaved", Context.UserIdentifier!);
}
private Guid CurrentUserId => Guid.Parse(Context.UserIdentifier!);
private Whiteboard CurrentWhiteboard => _whiteboardManager.GetWhiteboardForUser(CurrentUserId)!;
private async Task ResetCurrentUser()
{
await Clients.Caller.SendAsync("InitWhiteboard", CurrentWhiteboard);
}
private async Task SendToOthers(string methodName, object? arg)
{
await Clients.GroupExcept(CurrentWhiteboard.WhiteboardId.ToString(), Context.ConnectionId)
.SendAsync(methodName, arg);
}
public async Task AddRectangle(Rectangle rectangle)
{
var whiteboard = _whiteboardManager.GetWhiteboardForUser(Guid.Parse(Context.UserIdentifier!))!;
rectangle.OwnerId = Guid.Parse(Context.UserIdentifier!);
rectangle.OwnerId = CurrentUserId;
var whiteboard = CurrentWhiteboard;
await _messagingService.CreatedRectangle(whiteboard.WhiteboardId, rectangle);
whiteboard.AddRectangle(rectangle);
await Clients.GroupExcept(whiteboard.WhiteboardId.ToString(), Context.ConnectionId)
.SendAsync("AddedRectangle", rectangle);
await SendToOthers("AddedRectangle", rectangle);
}
public async Task AddArrow(Arrow arrow)
{
arrow.OwnerId = CurrentUserId;
var whiteboard = CurrentWhiteboard;
await _messagingService.CreatedArrow(whiteboard.WhiteboardId, arrow);
whiteboard.AddArrow(arrow);
await SendToOthers("AddedArrow", arrow);
}
public async Task AddLine(Line line)
{
line.OwnerId = CurrentUserId;
var whiteboard = CurrentWhiteboard;
await _messagingService.CreateLine(whiteboard.WhiteboardId, line);
whiteboard.AddLine(line);
await SendToOthers("AddedLine", line);
}
public async Task AddTextShape(TextShape textShape)
{
textShape.OwnerId = CurrentUserId;
var whiteboard = CurrentWhiteboard;
await _messagingService.CreateTextShape(whiteboard.WhiteboardId, textShape);
whiteboard.AddTextShape(textShape);
await SendToOthers("AddedTextShape", textShape);
}
public async Task MoveShape(MoveShapeCommand moveShape)
{
var whiteboard = CurrentWhiteboard;
var shape = whiteboard.Shapes.Find(s => s.Id.ToString() == moveShape.ShapeId);
if (shape is null || shape.OwnerId != CurrentUserId)
{
await ResetCurrentUser();
return;
}
shape.Move(new Position(moveShape.NewPositionX, moveShape.NewPositionY));
await SendToOthers("MovedShape", moveShape);
}
public async Task PlaceShape(MoveShapeCommand moveShape)
{
await MoveShape(moveShape);
await _messagingService.MoveShape(moveShape);
}
}

View File

@@ -7,4 +7,11 @@ public class Arrow : Shape
public Position EndPosition { get; set; }
public int Thickness { get; set; }
public override void Move(Position newPosition)
{
var difference = newPosition - EndPosition;
EndPosition += difference;
base.Move(newPosition);
}
}

View File

@@ -7,4 +7,11 @@ public class Line : Shape
public Position EndPosition { get; set; }
public int Thickness { get; set; }
public override void Move(Position newPosition)
{
var difference = newPosition - EndPosition;
EndPosition += difference;
base.Move(newPosition);
}
}

View File

@@ -11,6 +11,7 @@ public static class ShapeMappingExtensions
return new Rectangle()
{
Id = shape.Id,
OwnerId = shape.AuthorId,
Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
@@ -23,6 +24,7 @@ public static class ShapeMappingExtensions
return new Arrow()
{
Id = shape.Id,
OwnerId = shape.AuthorId,
Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
@@ -35,6 +37,7 @@ public static class ShapeMappingExtensions
return new Line()
{
Id = shape.Id,
OwnerId = shape.AuthorId,
Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
@@ -47,6 +50,7 @@ public static class ShapeMappingExtensions
return new TextShape()
{
Id = shape.Id,
OwnerId = shape.AuthorId,
Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color,
TextValue = shape.TextValue!,

View File

@@ -7,4 +7,11 @@ public class Rectangle : Shape
public Position EndPosition { get; set; }
public int BorderThickness { get; set; }
public override void Move(Position newPosition)
{
var difference = newPosition - Position;
EndPosition += difference;
base.Move(newPosition);
}
}

View File

@@ -11,4 +11,9 @@ public abstract class Shape
public Position Position { get; set; }
public string Color { get; set; }
public virtual void Move(Position newPosition)
{
Position = newPosition;
}
}

View File

@@ -10,4 +10,14 @@ public struct Position
X = x;
Y = y;
}
public static Position operator -(Position position, Position otherPosition)
{
return new Position(position.X - otherPosition.X, position.Y - otherPosition.Y);
}
public static Position operator +(Position position, Position otherPosition)
{
return new Position(position.X + otherPosition.X, position.Y + otherPosition.Y);
}
}

View File

@@ -1,3 +1,5 @@
using AipsCore.Application.Models.Shape.Command.CreateTextShape;
using AipsCore.Application.Models.Shape.Command.MoveShape;
using AipsRT.Model.Whiteboard.Shapes;
namespace AipsRT.Services.Interfaces;
@@ -5,4 +7,9 @@ namespace AipsRT.Services.Interfaces;
public interface IMessagingService
{
Task CreatedRectangle(Guid whiteboardId, Rectangle rectangle);
Task CreatedArrow(Guid whiteboardId, Arrow arrow);
Task CreateLine(Guid whiteboardId, Line line);
Task CreateTextShape(Guid whiteboardId, TextShape textShape);
Task MoveShape(MoveShapeCommand moveShape);
}

View File

@@ -1,6 +1,14 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Common.Message.AddArrow;
using AipsCore.Application.Common.Message.AddLine;
using AipsCore.Application.Common.Message.AddRectangle;
using AipsCore.Application.Common.Message.AddTextShape;
using AipsCore.Application.Common.Message.MoveShape;
using AipsCore.Application.Models.Shape.Command.CreateArrow;
using AipsCore.Application.Models.Shape.Command.CreateLine;
using AipsCore.Application.Models.Shape.Command.CreateRectangle;
using AipsCore.Application.Models.Shape.Command.CreateTextShape;
using AipsCore.Application.Models.Shape.Command.MoveShape;
using AipsRT.Model.Whiteboard.Shapes;
using AipsRT.Services.Interfaces;
@@ -33,4 +41,63 @@ public class MessagingService : IMessagingService
await _messagePublisher.PublishAsync(message);
}
public async Task CreatedArrow(Guid whiteboardId, Arrow arrow)
{
var command = new CreateArrowCommand(
arrow.Id.ToString(),
whiteboardId.ToString(),
arrow.OwnerId.ToString(),
arrow.Position.X,
arrow.Position.Y,
arrow.Color,
arrow.EndPosition.X,
arrow.EndPosition.Y,
arrow.Thickness);
var message = new AddArrowMessage(command);
await _messagePublisher.PublishAsync(message);
}
public async Task CreateLine(Guid whiteboardId, Line line)
{
var command = new CreateLineCommand(
line.Id.ToString(),
whiteboardId.ToString(),
line.OwnerId.ToString(),
line.Position.X,
line.Position.Y,
line.Color,
line.EndPosition.X,
line.EndPosition.Y,
line.Thickness);
var message = new AddLineMessage(command);
await _messagePublisher.PublishAsync(message);
}
public async Task CreateTextShape(Guid whiteboardId, TextShape textShape)
{
var command = new CreateTextShapeCommand(
textShape.Id.ToString(),
whiteboardId.ToString(),
textShape.OwnerId.ToString(),
textShape.Position.X,
textShape.Position.Y,
textShape.Color,
textShape.TextValue,
textShape.TextSize);
var message = new AddTextShapeMessage(command);
await _messagePublisher.PublishAsync(message);
}
public async Task MoveShape(MoveShapeCommand moveShape)
{
var message = new MoveShapeMessage(moveShape);
await _messagePublisher.PublishAsync(message);
}
}

View File

@@ -8,6 +8,7 @@ using AipsCore.Application.Models.User.Command.LogOutAll;
using AipsCore.Application.Models.User.Command.RefreshLogIn;
using AipsCore.Application.Models.User.Command.SignUp;
using AipsCore.Application.Models.User.Query.GetMe;
using AipsCore.Application.Models.User.Query.GetUser;
using AipsCore.Infrastructure.Persistence.User;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -80,4 +81,14 @@ public class UserController : ControllerBase
var result = await _dispatcher.Execute(new GetMeQuery(), cancellationToken);
return result;
}
[Authorize]
[HttpGet]
public async Task<ActionResult<GetUserQueryDto>> GetUser(string userId, CancellationToken cancellationToken)
{
var query = new GetUserQuery(userId);
var result = await _dispatcher.Execute(query, cancellationToken);
return Ok(result);
}
}

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>

View File

@@ -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<string | null>) {
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 }
}

View File

@@ -34,8 +34,9 @@ export const authService = {
async getMe() : Promise<User> {
const raw = await api.get<any>('/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 }
},
}

View File

@@ -0,0 +1,34 @@
import type { User } from '@/types'
import { api } from './api'
const userCache = new Map<string, User>()
const pendingRequests = new Map<string, Promise<User>>()
export async function fetchUser(userId: string): Promise<User> {
const cached = userCache.get(userId)
if (cached) return cached
const pending = pendingRequests.get(userId)
if (pending) return pending
const promise = api.get<any>(`/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()
}

View File

@@ -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<Whiteboard>('InitWhiteboard', callback)
},
@@ -32,6 +44,30 @@ export const whiteboardHubService = {
client.on<Rectangle>('AddedRectangle', callback)
},
onAddedArrow(callback: (arrow: Arrow) => void) {
client.on<Arrow>('AddedArrow', callback)
},
onAddedLine(callback: (line: Line) => void) {
client.on<Line>('AddedLine', callback)
},
onAddedTextShape(callback: (textShape: TextShape) => void) {
client.on<TextShape>('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<MoveShapeCommand>('MovedShape', callback)
},
onJoined(callback: (userId: string) => void) {
client.on<string>('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')
},

View File

@@ -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<Whiteboard | null>(null)
const selectedTool = ref<ShapeTool>('rectangle')
const selectedTool = ref<ShapeTool>('hand')
const isConnected = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedShapeId = ref<string | null>(null)
const selectedShapeType = ref<ShapeType | null>(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,
}
})

View File

@@ -1,6 +1,7 @@
import {type WhiteboardJoinPolicy, WhiteboardState} from "@/enums";
export interface User {
userId: string
username: string
email: string
}

View File

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