implement
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -28,8 +29,6 @@ public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand, Sha
|
||||
command.Thickness);
|
||||
|
||||
await _shapeRepository.SaveAsync(arrow, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return arrow.Id;
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
13
dotnet/AipsCore/Domain/Models/Shape/Shape.Move.cs
Normal file
13
dotnet/AipsCore/Domain/Models/Shape/Shape.Move.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
11
dotnet/AipsCore/Domain/Models/Shape/Sub/Arrow/Arrow.Move.cs
Normal file
11
dotnet/AipsCore/Domain/Models/Shape/Sub/Arrow/Arrow.Move.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
11
dotnet/AipsCore/Domain/Models/Shape/Sub/Line/Line.Move.cs
Normal file
11
dotnet/AipsCore/Domain/Models/Shape/Sub/Line/Line.Move.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<User>> GetUser(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new GetUserQuery(userId);
|
||||
var result = await _dispatcher.Execute(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
42
front/src/components/whiteboard/ShapeOwnerLabel.vue
Normal file
42
front/src/components/whiteboard/ShapeOwnerLabel.vue
Normal 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>
|
||||
61
front/src/components/whiteboard/ShapeOwnerTooltip.vue
Normal file
61
front/src/components/whiteboard/ShapeOwnerTooltip.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
47
front/src/components/whiteboard/shapes/SvgArrow.vue
Normal file
47
front/src/components/whiteboard/shapes/SvgArrow.vue
Normal 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>
|
||||
36
front/src/components/whiteboard/shapes/SvgDraftArrow.vue
Normal file
36
front/src/components/whiteboard/shapes/SvgDraftArrow.vue
Normal 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>
|
||||
23
front/src/components/whiteboard/shapes/SvgDraftLine.vue
Normal file
23
front/src/components/whiteboard/shapes/SvgDraftLine.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
23
front/src/components/whiteboard/shapes/SvgDraftText.vue
Normal file
23
front/src/components/whiteboard/shapes/SvgDraftText.vue
Normal 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>
|
||||
33
front/src/components/whiteboard/shapes/SvgLine.vue
Normal file
33
front/src/components/whiteboard/shapes/SvgLine.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
55
front/src/components/whiteboard/shapes/SvgTextShape.vue
Normal file
55
front/src/components/whiteboard/shapes/SvgTextShape.vue
Normal 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>
|
||||
51
front/src/composables/useShapeOwner.ts
Normal file
51
front/src/composables/useShapeOwner.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
34
front/src/services/userService.ts
Normal file
34
front/src/services/userService.ts
Normal 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()
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {type WhiteboardJoinPolicy, WhiteboardState} from "@/enums";
|
||||
|
||||
export interface User {
|
||||
userId: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user