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

@@ -20,6 +20,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Application\Models\Shape\Command\DeleteShape\" />
<Folder Include="Domain\Models\WhiteboardMembership\Validation\" /> <Folder Include="Domain\Models\WhiteboardMembership\Validation\" />
<Folder Include="Infrastructure\Persistence\Db\Migrations\" /> <Folder Include="Infrastructure\Persistence\Db\Migrations\" />
</ItemGroup> </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; namespace AipsCore.Application.Models.Shape.Command.CreateArrow;
public record CreateArrowCommand( public record CreateArrowCommand(
string Id,
string WhiteboardId, string WhiteboardId,
string AuthorId, string AuthorId,
int PositionX, int PositionX,
@@ -11,4 +12,4 @@ public record CreateArrowCommand(
string Color, string Color,
int EndPositionX, int EndPositionX,
int EndPositionY, 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; namespace AipsCore.Application.Models.Shape.Command.CreateArrow;
public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand, ShapeId> public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand>
{ {
private readonly IShapeRepository _shapeRepository; private readonly IShapeRepository _shapeRepository;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
@@ -16,9 +16,10 @@ public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand, Sha
_unitOfWork = unitOfWork; _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( var arrow = Domain.Models.Shape.Sub.Arrow.Arrow.Create(
command.Id,
command.WhiteboardId, command.WhiteboardId,
command.AuthorId, command.AuthorId,
command.PositionX, command.PositionY, command.PositionX, command.PositionY,
@@ -29,7 +30,5 @@ public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand, Sha
await _shapeRepository.SaveAsync(arrow, cancellationToken); await _shapeRepository.SaveAsync(arrow, cancellationToken);
await _unitOfWork.SaveChangesAsync(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; namespace AipsCore.Application.Models.Shape.Command.CreateLine;
public record CreateLineCommand( public record CreateLineCommand(
string Id,
string WhiteboardId, string WhiteboardId,
string AuthorId, string AuthorId,
int PositionX, int PositionX,
@@ -11,4 +12,4 @@ public record CreateLineCommand(
string Color, string Color,
int EndPositionX, int EndPositionX,
int EndPositionY, 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; namespace AipsCore.Application.Models.Shape.Command.CreateLine;
public class CreateLineCommandHandler : ICommandHandler<CreateLineCommand, ShapeId> public class CreateLineCommandHandler : ICommandHandler<CreateLineCommand>
{ {
private readonly IShapeRepository _shapeRepository; private readonly IShapeRepository _shapeRepository;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
@@ -16,9 +16,10 @@ public class CreateLineCommandHandler : ICommandHandler<CreateLineCommand, Shape
_unitOfWork = unitOfWork; _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( var line = Domain.Models.Shape.Sub.Line.Line.Create(
command.Id,
command.WhiteboardId, command.WhiteboardId,
command.AuthorId, command.AuthorId,
command.PositionX, command.PositionX,
@@ -30,7 +31,5 @@ public class CreateLineCommandHandler : ICommandHandler<CreateLineCommand, Shape
await _shapeRepository.SaveAsync(line, cancellationToken); await _shapeRepository.SaveAsync(line, cancellationToken);
await _unitOfWork.SaveChangesAsync(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; namespace AipsCore.Application.Models.Shape.Command.CreateTextShape;
public record CreateTextShapeCommand( public record CreateTextShapeCommand(
string Id,
string WhiteboardId, string WhiteboardId,
string AuthorId, string AuthorId,
int PositionX, int PositionX,
int PositionY, int PositionY,
string Color, string Color,
string Text, 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; namespace AipsCore.Application.Models.Shape.Command.CreateTextShape;
public class CreateTextShapeCommandHandler : ICommandHandler<CreateTextShapeCommand, ShapeId> public class CreateTextShapeCommandHandler : ICommandHandler<CreateTextShapeCommand>
{ {
private readonly IShapeRepository _shapeRepository; private readonly IShapeRepository _shapeRepository;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
@@ -17,9 +17,10 @@ public class CreateTextShapeCommandHandler : ICommandHandler<CreateTextShapeComm
_unitOfWork = unitOfWork; _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( var textShape = TextShape.Create(
command.Id,
command.WhiteboardId, command.WhiteboardId,
command.AuthorId, command.AuthorId,
command.PositionX, command.PositionX,
@@ -30,7 +31,5 @@ public class CreateTextShapeCommandHandler : ICommandHandler<CreateTextShapeComm
await _shapeRepository.SaveAsync(textShape, cancellationToken); await _shapeRepository.SaveAsync(textShape, cancellationToken);
await _unitOfWork.SaveChangesAsync(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

@@ -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; namespace AipsCore.Domain.Models.Shape;
public abstract class Shape : DomainModel<ShapeId> public abstract partial class Shape : DomainModel<ShapeId>
{ {
public WhiteboardId WhiteboardId { get; private set; } 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; namespace AipsCore.Domain.Models.Shape.Sub.Arrow;
public class Arrow : Shape public partial class Arrow : Shape
{ {
public Position EndPosition { get; private set; } public Position EndPosition { get; private set; }
public Thickness Thickness { 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; namespace AipsCore.Domain.Models.Shape.Sub.Line;
public class Line : Shape public partial class Line : Shape
{ {
public Position EndPosition { get; private set; } public Position EndPosition { get; private set; }
public Thickness Thickness { 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; namespace AipsCore.Domain.Models.Shape.Sub.Rectangle;
public class Rectangle : Shape public partial class Rectangle : Shape
{ {
public override ShapeType ShapeType => ShapeType.Rectangle; public override ShapeType ShapeType => ShapeType.Rectangle;
public Position EndPosition { get; } public Position EndPosition { get; set; }
public Thickness BorderThickness { get; } 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 record Position : AbstractValueObject
{ {
public int X { get; } public int X { get; set; }
public int Y { get; } public int Y { get; set; }
public Position(int x, int y) 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( return Rectangle.Create(
shape.Id.ToString(), shape.Id.ToString(),
shape.WhiteboardId.ToString(), shape.WhiteboardId.ToString(),
shape.AuthorId.ToString(),
shape.PositionX, shape.PositionY, shape.PositionX, shape.PositionY,
shape.Color, shape.Color,
shape.EndPositionX!.Value, shape.EndPositionY!.Value, shape.EndPositionX!.Value, shape.EndPositionY!.Value,

View File

@@ -1,6 +1,9 @@
using AipsCore.Application.Abstract.MessageBroking; 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;
using AipsRT.Model.Whiteboard.Shapes; using AipsRT.Model.Whiteboard.Shapes;
using AipsRT.Model.Whiteboard.Structs;
using AipsRT.Services.Interfaces; using AipsRT.Services.Interfaces;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
@@ -41,17 +44,89 @@ public class WhiteboardHub : Hub
.SendAsync("Leaved", Context.UserIdentifier!); .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) public async Task AddRectangle(Rectangle rectangle)
{ {
var whiteboard = _whiteboardManager.GetWhiteboardForUser(Guid.Parse(Context.UserIdentifier!))!; rectangle.OwnerId = CurrentUserId;
var whiteboard = CurrentWhiteboard;
rectangle.OwnerId = Guid.Parse(Context.UserIdentifier!);
await _messagingService.CreatedRectangle(whiteboard.WhiteboardId, rectangle); await _messagingService.CreatedRectangle(whiteboard.WhiteboardId, rectangle);
whiteboard.AddRectangle(rectangle); whiteboard.AddRectangle(rectangle);
await Clients.GroupExcept(whiteboard.WhiteboardId.ToString(), Context.ConnectionId) await SendToOthers("AddedRectangle", rectangle);
.SendAsync("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 Position EndPosition { get; set; }
public int Thickness { 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 Position EndPosition { get; set; }
public int Thickness { 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() return new Rectangle()
{ {
Id = shape.Id, Id = shape.Id,
OwnerId = shape.AuthorId,
Position = new Position(shape.PositionX, shape.PositionY), Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color, Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value), EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
@@ -23,6 +24,7 @@ public static class ShapeMappingExtensions
return new Arrow() return new Arrow()
{ {
Id = shape.Id, Id = shape.Id,
OwnerId = shape.AuthorId,
Position = new Position(shape.PositionX, shape.PositionY), Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color, Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value), EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
@@ -35,6 +37,7 @@ public static class ShapeMappingExtensions
return new Line() return new Line()
{ {
Id = shape.Id, Id = shape.Id,
OwnerId = shape.AuthorId,
Position = new Position(shape.PositionX, shape.PositionY), Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color, Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value), EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
@@ -47,6 +50,7 @@ public static class ShapeMappingExtensions
return new TextShape() return new TextShape()
{ {
Id = shape.Id, Id = shape.Id,
OwnerId = shape.AuthorId,
Position = new Position(shape.PositionX, shape.PositionY), Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color, Color = shape.Color,
TextValue = shape.TextValue!, TextValue = shape.TextValue!,

View File

@@ -7,4 +7,11 @@ public class Rectangle : Shape
public Position EndPosition { get; set; } public Position EndPosition { get; set; }
public int BorderThickness { 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 Position Position { get; set; }
public string Color { 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; X = x;
Y = y; 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; using AipsRT.Model.Whiteboard.Shapes;
namespace AipsRT.Services.Interfaces; namespace AipsRT.Services.Interfaces;
@@ -5,4 +7,9 @@ namespace AipsRT.Services.Interfaces;
public interface IMessagingService public interface IMessagingService
{ {
Task CreatedRectangle(Guid whiteboardId, Rectangle rectangle); 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.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.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.CreateRectangle;
using AipsCore.Application.Models.Shape.Command.CreateTextShape;
using AipsCore.Application.Models.Shape.Command.MoveShape;
using AipsRT.Model.Whiteboard.Shapes; using AipsRT.Model.Whiteboard.Shapes;
using AipsRT.Services.Interfaces; using AipsRT.Services.Interfaces;
@@ -33,4 +41,63 @@ public class MessagingService : IMessagingService
await _messagePublisher.PublishAsync(message); 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.RefreshLogIn;
using AipsCore.Application.Models.User.Command.SignUp; using AipsCore.Application.Models.User.Command.SignUp;
using AipsCore.Application.Models.User.Query.GetMe; using AipsCore.Application.Models.User.Query.GetMe;
using AipsCore.Application.Models.User.Query.GetUser;
using AipsCore.Infrastructure.Persistence.User; using AipsCore.Infrastructure.Persistence.User;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -80,4 +81,14 @@ public class UserController : ControllerBase
var result = await _dispatcher.Execute(new GetMeQuery(), cancellationToken); var result = await _dispatcher.Execute(new GetMeQuery(), cancellationToken);
return result; return result;
} }
[Authorize]
[HttpGet]
public async Task<ActionResult<User>> 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"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, nextTick } from 'vue'
import { useWhiteboardStore } from '@/stores/whiteboard.ts' 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 SvgRectangle from '@/components/whiteboard/shapes/SvgRectangle.vue'
import SvgDraftRect from '@/components/whiteboard/shapes/SvgDraftRect.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 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 isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 }) const dragStart = ref({ x: 0, y: 0 })
const dragEnd = 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(() => { 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 x = Math.min(dragStart.value.x, dragEnd.value.x)
const y = Math.min(dragStart.value.y, dragEnd.value.y) const y = Math.min(dragStart.value.y, dragEnd.value.y)
const width = Math.abs(dragEnd.value.x - dragStart.value.x) const width = Math.abs(dragEnd.value.x - dragStart.value.x)
@@ -20,9 +85,77 @@ const draftRect = computed(() => {
return { x, y, width, height } 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) { function onMouseDown(e: MouseEvent) {
if (store.selectedTool !== 'rectangle') return if (textInputState.value.active) return
const svg = (e.currentTarget as SVGSVGElement)
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 rect = svg.getBoundingClientRect()
const x = e.clientX - rect.left const x = e.clientX - rect.left
const y = e.clientY - rect.top const y = e.clientY - rect.top
@@ -32,51 +165,216 @@ function onMouseDown(e: MouseEvent) {
} }
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return if (isMoving.value && movingShapeId.value) {
const svg = (e.currentTarget as SVGSVGElement) 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() const rect = svg.getBoundingClientRect()
dragEnd.value = { dragEnd.value = {
x: e.clientX - rect.left, x: e.clientX - rect.left,
y: e.clientY - rect.top, 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() { 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 if (!isDragging.value) return
isDragging.value = false isDragging.value = false
const x1 = Math.min(dragStart.value.x, dragEnd.value.x) const sx = dragStart.value.x
const y1 = Math.min(dragStart.value.y, dragEnd.value.y) const sy = dragStart.value.y
const x2 = Math.max(dragStart.value.x, dragEnd.value.x) const ex = dragEnd.value.x
const y2 = Math.max(dragStart.value.y, dragEnd.value.y) 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
if (store.selectedTool === 'rectangle') {
const rectangle: Rectangle = { const rectangle: Rectangle = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
ownerId: '00000000-0000-0000-0000-000000000000', ownerId: auth.user?.userId ?? '',
position: { x: Math.round(x1), y: Math.round(y1) }, position: { x: Math.round(Math.min(sx, ex)), y: Math.round(Math.min(sy, ey)) },
endPosition: { x: Math.round(x2), y: Math.round(y2) }, endPosition: { x: Math.round(Math.max(sx, ex)), y: Math.round(Math.max(sy, ey)) },
color: '#4f9dff', color: store.toolColor,
borderThickness: 2, borderThickness: store.toolThickness,
} }
store.addRectangle(rectangle) 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')
}
}
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> </script>
<template> <template>
<svg <div
class="whiteboard-canvas" class="canvas-container"
@mousedown="onMouseDown" @mousedown="onMouseDown"
@mousemove="onMouseMove" @mousemove="onMouseMove"
@mouseup="onMouseUp" @mouseup="onMouseUp"
@mouseleave="onMouseUp" @mouseleave="onMouseLeave"
> >
<svg :class="['whiteboard-canvas', store.selectedTool === 'hand' ? 'select-mode' : 'draw-mode']">
<SvgRectangle <SvgRectangle
v-for="rect in store.whiteboard?.rectangles" v-for="rect in store.whiteboard?.rectangles"
:key="rect.id" :key="rect.id"
:rectangle="rect" :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 <SvgDraftRect
v-if="draftRect" v-if="draftRect"
@@ -84,17 +382,102 @@ function onMouseUp() {
:y="draftRect.y" :y="draftRect.y"
:width="draftRect.width" :width="draftRect.width"
:height="draftRect.height" :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> </svg>
<ShapeOwnerTooltip
v-if="hoveredOwnerId && hoveredShapeId"
:owner-id="hoveredOwnerId"
:cursor-x="cursorX"
:cursor-y="cursorY"
/>
<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"
/>
</div>
</template> </template>
<style scoped> <style scoped>
.whiteboard-canvas { .canvas-container {
position: relative;
flex: 1; flex: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
}
.whiteboard-canvas {
width: 100%;
height: 100%;
background-color: #1a1a2e; background-color: #1a1a2e;
cursor: crosshair;
display: block; 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> </style>

View File

@@ -1,44 +1,133 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useWhiteboardStore } from '@/stores/whiteboard.ts' 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 store = useWhiteboardStore()
const emit = defineEmits<{ leave: [] }>() const emit = defineEmits<{ leave: [] }>()
const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[] = [ const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[] = [
{ name: 'rectangle', label: 'Rectangle', icon: '', enabled: true }, { name: 'hand', label: 'Select', icon: '\u270B', enabled: true },
{ name: 'arrow', label: 'Arrow', icon: '', enabled: false }, { name: 'rectangle', label: 'Rectangle', icon: '\u25AD', enabled: true },
{ name: 'line', label: 'Line', icon: '', enabled: false }, { name: 'arrow', label: 'Arrow', icon: '\u2192', enabled: true },
{ name: 'text', label: 'Text', icon: 'T', enabled: false }, { 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> </script>
<template> <template>
<div class="toolbar d-flex flex-column align-items-center py-2 gap-2"> <div class="toolbar">
<div class="tools-grid">
<button <button
v-for="tool in tools" v-for="tool in tools"
:key="tool.name" :key="tool.name"
class="btn btn-sm" class="tool-btn"
:class="[ :class="{ active: store.selectedTool === tool.name, disabled: !tool.enabled }"
store.selectedTool === tool.name ? 'btn-primary' : 'btn-outline-secondary',
{ disabled: !tool.enabled },
]"
:disabled="!tool.enabled" :disabled="!tool.enabled"
:title="tool.enabled ? tool.label : `${tool.label} (coming soon)`" :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)" @click="tool.enabled && store.selectTool(tool.name)"
> >
{{ tool.icon }} {{ tool.icon }}
</button> </button>
</div>
<div class="mt-auto mb-2"> <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 <button
class="btn btn-sm btn-outline-danger" class="btn btn-sm btn-outline-danger leave-btn"
title="Leave whiteboard" title="Leave whiteboard"
style="width: 40px; height: 40px"
@click="emit('leave')" @click="emit('leave')"
> >
Leave
</button> </button>
</div> </div>
</div> </div>
@@ -46,9 +135,96 @@ const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[
<style scoped> <style scoped>
.toolbar { .toolbar {
width: 56px; width: 180px;
background-color: #0d0d1a; background-color: #0d0d1a;
border-right: 1px solid #2a2a3e; border-right: 1px solid #2a2a3e;
height: 100%; 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> </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 y: number
width: number width: number
height: number height: number
color: string
thickness: number
}>() }>()
</script> </script>
@@ -13,8 +15,8 @@ const props = defineProps<{
:y="props.y" :y="props.y"
:width="props.width" :width="props.width"
:height="props.height" :height="props.height"
stroke="#4f9dff" :stroke="props.color"
:stroke-width="2" :stroke-width="props.thickness"
stroke-dasharray="6 3" stroke-dasharray="6 3"
fill="none" fill="none"
opacity="0.7" 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"> <script setup lang="ts">
import { computed } from 'vue'
import type { Rectangle } from '@/types/whiteboard.ts' 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> </script>
<template> <template>
<g :data-shape-id="props.rectangle.id" data-shape-type="rectangle">
<rect <rect
:x="Math.min(props.rectangle.position.x, props.rectangle.endPosition.x)" :x="x"
:y="Math.min(props.rectangle.position.y, props.rectangle.endPosition.y)" :y="y"
:width="Math.abs(props.rectangle.endPosition.x - props.rectangle.position.x)" :width="w"
:height="Math.abs(props.rectangle.endPosition.y - props.rectangle.position.y)" :height="h"
:stroke="props.rectangle.color" :stroke="props.rectangle.color"
:stroke-width="props.rectangle.borderThickness" :stroke-width="props.rectangle.borderThickness"
fill="none" 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> </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> { async getMe() : Promise<User> {
const raw = await api.get<any>('/api/User/me') const raw = await api.get<any>('/api/User/me')
// backend User may have fields like userName / UserName and email / Email // 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 username = raw?.userName ?? raw?.UserName ?? raw?.username ?? raw?.name ?? ''
const email = raw?.email ?? raw?.Email ?? '' 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 { 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`) const client = new SignalRService(`/hubs/whiteboard`)
@@ -24,6 +24,18 @@ export const whiteboardHubService = {
await client.invoke('AddRectangle', rectangle) 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) { onInitWhiteboard(callback: (whiteboard: Whiteboard) => void) {
client.on<Whiteboard>('InitWhiteboard', callback) client.on<Whiteboard>('InitWhiteboard', callback)
}, },
@@ -32,6 +44,30 @@ export const whiteboardHubService = {
client.on<Rectangle>('AddedRectangle', callback) 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) { onJoined(callback: (userId: string) => void) {
client.on<string>('Joined', callback) client.on<string>('Joined', callback)
}, },
@@ -43,6 +79,10 @@ export const whiteboardHubService = {
offAll() { offAll() {
client.off('InitWhiteboard') client.off('InitWhiteboard')
client.off('AddedRectangle') client.off('AddedRectangle')
client.off('AddedArrow')
client.off('AddedLine')
client.off('AddedTextShape')
client.off('MovedShape')
client.off('Joined') client.off('Joined')
client.off('Leaved') client.off('Leaved')
}, },

View File

@@ -1,15 +1,32 @@
import { ref } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' 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' import { whiteboardHubService } from '@/services/whiteboardHubService.ts'
export const useWhiteboardStore = defineStore('whiteboard', () => { export const useWhiteboardStore = defineStore('whiteboard', () => {
const whiteboard = ref<Whiteboard | null>(null) const whiteboard = ref<Whiteboard | null>(null)
const selectedTool = ref<ShapeTool>('rectangle') const selectedTool = ref<ShapeTool>('hand')
const isConnected = ref(false) const isConnected = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) 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) { async function joinWhiteboard(id: string) {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
@@ -27,6 +44,22 @@ export const useWhiteboardStore = defineStore('whiteboard', () => {
whiteboard.value?.rectangles.push(rectangle) 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) => { whiteboardHubService.onJoined((userId) => {
console.log('User joined:', userId) console.log('User joined:', userId)
}) })
@@ -56,7 +89,8 @@ export const useWhiteboardStore = defineStore('whiteboard', () => {
whiteboard.value = null whiteboard.value = null
isConnected.value = false isConnected.value = false
selectedTool.value = 'rectangle' selectedTool.value = 'hand'
deselectShape()
error.value = null 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) { function selectTool(tool: ShapeTool) {
selectedTool.value = tool 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 { return {
whiteboard, whiteboard,
selectedTool, selectedTool,
isConnected, isConnected,
isLoading, isLoading,
error, error,
selectedShapeId,
selectedShapeType,
selectedShape,
toolColor,
toolThickness,
toolTextSize,
joinWhiteboard, joinWhiteboard,
leaveWhiteboard, leaveWhiteboard,
addRectangle, addRectangle,
addArrow,
addLine,
addTextShape,
selectTool, selectTool,
selectShape,
deselectShape,
applyMoveShape,
setToolColor,
setToolThickness,
setToolTextSize,
} }
}) })

View File

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

View File

@@ -39,4 +39,11 @@ export interface Whiteboard {
textShapes: TextShape[] 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'