Merge pull request #40 from StewKI/feature-more-tools
Tools improvements & moving shapes
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
@@ -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,
|
||||||
@@ -28,8 +29,6 @@ public class CreateArrowCommandHandler : ICommandHandler<CreateArrowCommand, Sha
|
|||||||
command.Thickness);
|
command.Thickness);
|
||||||
|
|
||||||
await _shapeRepository.SaveAsync(arrow, cancellationToken);
|
await _shapeRepository.SaveAsync(arrow, cancellationToken);
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return arrow.Id;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,4 @@ using AipsCore.Application.Abstract.Query;
|
|||||||
|
|
||||||
namespace AipsCore.Application.Models.User.Query.GetUser;
|
namespace AipsCore.Application.Models.User.Query.GetUser;
|
||||||
|
|
||||||
public record GetUserQuery(string UserId) : IQuery<Infrastructure.Persistence.User.User>;
|
public record GetUserQuery(string UserId) : IQuery<GetUserQueryDto>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace AipsCore.Application.Models.User.Query.GetUser;
|
||||||
|
|
||||||
|
public record GetUserQueryDto(string Id, string Email, string UserName);
|
||||||
@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace AipsCore.Application.Models.User.Query.GetUser;
|
namespace AipsCore.Application.Models.User.Query.GetUser;
|
||||||
|
|
||||||
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, Infrastructure.Persistence.User.User>
|
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, GetUserQueryDto>
|
||||||
{
|
{
|
||||||
private readonly AipsDbContext _context;
|
private readonly AipsDbContext _context;
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ public class GetUserQueryHandler : IQueryHandler<GetUserQuery, Infrastructure.Pe
|
|||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Infrastructure.Persistence.User.User> Handle(GetUserQuery query, CancellationToken cancellationToken = default)
|
public async Task<GetUserQueryDto> Handle(GetUserQuery query, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var result = await _context.Users
|
var result = await _context.Users
|
||||||
.Where(u => u.Id.ToString() == query.UserId)
|
.Where(u => u.Id.ToString() == query.UserId)
|
||||||
@@ -27,6 +27,6 @@ public class GetUserQueryHandler : IQueryHandler<GetUserQuery, Infrastructure.Pe
|
|||||||
throw new ValidationException(UserErrors.NotFound(new UserId(query.UserId)));
|
throw new ValidationException(UserErrors.NotFound(new UserId(query.UserId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return new GetUserQueryDto(result.Id.ToString(), result.Email!, result.UserName!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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;
|
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; }
|
||||||
|
|
||||||
|
|||||||
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;
|
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; }
|
||||||
|
|||||||
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;
|
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; }
|
||||||
|
|||||||
@@ -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;
|
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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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 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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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!,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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<GetUserQueryDto>> 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">
|
<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,69 +165,319 @@ 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 rect = svg.getBoundingClientRect()
|
const dx = x - moveStartMouse.value.x
|
||||||
dragEnd.value = {
|
const dy = y - moveStartMouse.value.y
|
||||||
x: e.clientX - rect.left,
|
const newPosX = moveStartPos.value.x + dx
|
||||||
y: e.clientY - rect.top,
|
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() {
|
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
|
||||||
|
|
||||||
const rectangle: Rectangle = {
|
if (store.selectedTool === 'rectangle') {
|
||||||
id: crypto.randomUUID(),
|
const rectangle: Rectangle = {
|
||||||
ownerId: '00000000-0000-0000-0000-000000000000',
|
id: crypto.randomUUID(),
|
||||||
position: { x: Math.round(x1), y: Math.round(y1) },
|
ownerId: auth.user?.userId ?? '',
|
||||||
endPosition: { x: Math.round(x2), y: Math.round(y2) },
|
position: { x: Math.round(Math.min(sx, ex)), y: Math.round(Math.min(sy, ey)) },
|
||||||
color: '#4f9dff',
|
endPosition: { x: Math.round(Math.max(sx, ex)), y: Math.round(Math.max(sy, ey)) },
|
||||||
borderThickness: 2,
|
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>
|
</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"
|
||||||
>
|
>
|
||||||
<SvgRectangle
|
<svg :class="['whiteboard-canvas', store.selectedTool === 'hand' ? 'select-mode' : 'draw-mode']">
|
||||||
v-for="rect in store.whiteboard?.rectangles"
|
<SvgRectangle
|
||||||
:key="rect.id"
|
v-for="rect in store.whiteboard?.rectangles"
|
||||||
:rectangle="rect"
|
: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
|
<input
|
||||||
v-if="draftRect"
|
v-if="textInputState.active"
|
||||||
:x="draftRect.x"
|
ref="textInputRef"
|
||||||
:y="draftRect.y"
|
v-model="textInputState.value"
|
||||||
:width="draftRect.width"
|
class="text-input-overlay"
|
||||||
:height="draftRect.height"
|
: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>
|
</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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<button
|
<div class="tools-grid">
|
||||||
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">
|
|
||||||
<button
|
<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"
|
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>
|
||||||
|
|||||||
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
|
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"
|
||||||
|
|||||||
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">
|
<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>
|
||||||
<rect
|
<g :data-shape-id="props.rectangle.id" data-shape-type="rectangle">
|
||||||
:x="Math.min(props.rectangle.position.x, props.rectangle.endPosition.x)"
|
<rect
|
||||||
:y="Math.min(props.rectangle.position.y, props.rectangle.endPosition.y)"
|
:x="x"
|
||||||
:width="Math.abs(props.rectangle.endPosition.x - props.rectangle.position.x)"
|
:y="y"
|
||||||
:height="Math.abs(props.rectangle.endPosition.y - props.rectangle.position.y)"
|
:width="w"
|
||||||
:stroke="props.rectangle.color"
|
:height="h"
|
||||||
:stroke-width="props.rectangle.borderThickness"
|
:stroke="props.rectangle.color"
|
||||||
fill="none"
|
: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>
|
</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> {
|
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 }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user