diff --git a/monosketch-svelte/src/lib/mono/shape/command/shape-manager-commands.ts b/monosketch-svelte/src/lib/mono/shape/command/shape-manager-commands.ts new file mode 100644 index 000000000..30ed2cc37 --- /dev/null +++ b/monosketch-svelte/src/lib/mono/shape/command/shape-manager-commands.ts @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2024, tuanchauict + */ + +import { AddPosition, MoveActionType } from "$mono/shape/collection/quick-list"; +import { type Command, ShapeManager } from "$mono/shape/shape-manager"; +import type { AbstractShape } from "$mono/shape/shape/abstract-shape"; +import { Group } from "$mono/shape/shape/group"; + +/** + * A [Command] for adding new shape into [ShapeManager]. + */ +export class AddShape implements Command { + constructor(private readonly shape: AbstractShape) { + this.shape = shape; + } + + getDirectAffectedParent(shapeManager: ShapeManager): Group | null { + return shapeManager.getGroup(this.shape.parentId); + } + + execute(shapeManager: ShapeManager, parent: Group) { + parent.add(this.shape); + this.shape.parentId = parent.id; + shapeManager.register(this.shape); + } +} + +/** + * A [Command] for removing a shape from [ShapeManager]. + */ +export class RemoveShape implements Command { + constructor(private readonly shape: AbstractShape) { + this.shape = shape; + } + + getDirectAffectedParent(shapeManager: ShapeManager): Group | null { + return shapeManager.getGroup(this.shape.parentId); + } + + execute(shapeManager: ShapeManager, parent: Group) { + parent.remove(this.shape); + shapeManager.unregister(this.shape); + + if (parent === shapeManager.root) { + return; + } + switch (parent.itemCount) { + case 1: + shapeManager.execute(new Ungroup(parent)); + break; + case 0: + shapeManager.execute(new RemoveShape(parent)); + break; + } + } +} + +/** + * A [Command] for grouping shapes. + */ +export class GroupShapes implements Command { + constructor(private readonly sameParentShapes: AbstractShape[]) { + this.sameParentShapes = sameParentShapes; + } + + getDirectAffectedParent(shapeManager: ShapeManager): Group | null { + if (this.sameParentShapes.length < 2) { + return null; + } + const parentId = this.sameParentShapes[0].parentId; + if (this.sameParentShapes.some(shape => shape.parentId !== parentId)) { + return null; + } + return shapeManager.getGroup(parentId); + } + + execute(shapeManager: ShapeManager, parent: Group) { + const group = Group.create({ parentId: parent.id }); + parent.add(group, AddPosition.After(this.sameParentShapes[this.sameParentShapes.length - 1])); + shapeManager.register(group); + + for (const shape of this.sameParentShapes) { + parent.remove(shape); + shape.parentId = group.id; + group.add(shape); + } + } +} + +/** + * A [Command] for decomposing a [Group]. + */ +export class Ungroup implements Command { + constructor(private readonly group: Group) { + this.group = group; + } + + getDirectAffectedParent(shapeManager: ShapeManager): Group | null { + return shapeManager.getGroup(this.group.parentId); + } + + execute(shapeManager: ShapeManager, parent: Group) { + const items = this.group.itemArray.reverse(); + + for (const shape of items) { + this.group.remove(shape); + shape.parentId = null; + parent.add(shape, AddPosition.After(this.group)); + } + shapeManager.execute(new RemoveShape(this.group)); + } +} + +/** + * A [Command] for changing order of a shape. + */ +export class ChangeOrder implements Command { + + constructor(private readonly shape: AbstractShape, private readonly changeOrderType: ChangeOrderType) { + this.shape = shape; + this.changeOrderType = changeOrderType; + } + + getDirectAffectedParent(shapeManager: ShapeManager): Group | null { + return shapeManager.getGroup(this.shape.parentId); + } + + execute(_shapeManager: ShapeManager, parent: Group) { + parent.changeOrder(this.shape, ChangeOrderTypeToMoveActionType[this.changeOrderType]); + } +} + +export enum ChangeOrderType { + FORWARD, + BACKWARD, + FRONT, + BACK, +} + +const ChangeOrderTypeToMoveActionType: Record = { + [ChangeOrderType.FORWARD]: MoveActionType.DOWN, + [ChangeOrderType.BACKWARD]: MoveActionType.UP, + [ChangeOrderType.FRONT]: MoveActionType.BOTTOM, + [ChangeOrderType.BACK]: MoveActionType.TOP, +}; diff --git a/monosketch-svelte/src/lib/mono/shape/interaction-bound.ts b/monosketch-svelte/src/lib/mono/shape/interaction-bound.ts index e348b01ab..aea76c806 100644 --- a/monosketch-svelte/src/lib/mono/shape/interaction-bound.ts +++ b/monosketch-svelte/src/lib/mono/shape/interaction-bound.ts @@ -14,6 +14,9 @@ export interface InteractionBound { readonly interactionPoints: InteractionPoint[]; } +/** + * A class which defines interaction bound for scalable shapes. + */ export class ScalableInteractionBound implements InteractionBound { readonly type = InteractionBoundType.SCALABLE_SHAPE; diff --git a/monosketch-svelte/src/lib/mono/shape/shape-manager.test.ts b/monosketch-svelte/src/lib/mono/shape/shape-manager.test.ts new file mode 100644 index 000000000..7fc58f235 --- /dev/null +++ b/monosketch-svelte/src/lib/mono/shape/shape-manager.test.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2024, tuanchauict + */ + +import { Rect } from "$libs/graphics-geo/rect"; +import { + AddShape, + ChangeOrder, + ChangeOrderType, + GroupShapes, + RemoveShape, + Ungroup, +} from "$mono/shape/command/shape-manager-commands"; +import { ShapeManager } from "$mono/shape/shape-manager"; +import type { AbstractShape } from "$mono/shape/shape/abstract-shape"; +import { Group } from "$mono/shape/shape/group"; +import { MockShape } from "$mono/shape/shape/mock-shape"; +import { Rectangle } from "$mono/shape/shape/rectangle"; +import { beforeEach, describe, expect, test } from "vitest"; + +describe('ShapeManagerTest', () => { + let target: ShapeManager; + + beforeEach(() => { + target = new ShapeManager(); + }); + + const addShape = (shape: AbstractShape) => { + target.execute(new AddShape(shape)); + }; + + test('testExecute_Add', () => { + const shape1 = new MockShape(Rect.ZERO); + target.execute(new AddShape(shape1)); + expect(target.root.itemArray).toStrictEqual([shape1]); + + const group1 = Group.create(); + target.execute(new AddShape(group1)); + expect(target.root.itemArray).toEqual([shape1, group1]); + + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group1.id }); + target.execute(new AddShape(shape2)); + expect(target.root.itemArray).toEqual([shape1, group1]); + expect(group1.itemArray).toEqual([shape2]); + + const shape3 = new MockShape(Rect.ZERO, '1000'); + target.execute(new AddShape(shape3)); + expect(target.root.itemArray).toEqual([shape1, group1]); + expect(group1.itemArray).toEqual([shape2]); + }); + + test('testExecute_Remove_singleGroupItem_removeGroup', () => { + const group = Group.create(); + const shape = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + + addShape(group); + addShape(shape); + target.execute(new RemoveShape(shape)); + expect(target.root.itemCount).toBe(0); + }); + + test('testExecute_Remove_removeGroupItem_ungroup', () => { + const group = Group.create(); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + const shape3 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + + addShape(group); + addShape(shape1); + addShape(shape2); + addShape(shape3); + + target.execute(new RemoveShape(shape3)); + expect(target.root.itemArray).toEqual([shape2, shape1]); + expect(shape2.parentId).toBe(target.root.id); + }); + + test('testExecute_Remove_removeGroupItem_unchangeRoot', () => { + const group = Group.create(); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + const shape3 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + const shape4 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + + addShape(group); + addShape(shape1); + addShape(shape2); + addShape(shape3); + addShape(shape4); + + target.execute(new RemoveShape(shape4)); + expect(target.root.itemArray).toEqual([group, shape1]); + expect(group.itemArray).toEqual([shape2, shape3]); + }); + + test('testExecute_Group_invalid', () => { + const group = Group.create(); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + + addShape(group); + addShape(shape1); + addShape(shape2); + + target.execute(new GroupShapes([shape1])); + expect(target.root.itemArray).toEqual([group, shape1]); + + target.execute(new GroupShapes([shape1, shape2])); + expect(target.root.itemArray).toEqual([group, shape1]); + expect(group.itemArray).toEqual([shape2]); + }); + + test('testExecute_Group_valid', () => { + const shape0 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape3 = Rectangle.fromRect({ rect: Rect.ZERO }); + + addShape(shape0); + addShape(shape1); + addShape(shape2); + addShape(shape3); + target.execute(new GroupShapes([shape1, shape2])); + + const items = target.root.itemArray; + + expect(target.root.itemCount).toBe(3); + expect(items[0]).toBe(shape0); + expect(items[2]).toBe(shape3); + const group = items[1] as Group; + expect(group.itemArray).toEqual([shape1, shape2]); + expect(target.getGroup(shape1.parentId)).toBe(group); + expect(target.getGroup(shape2.parentId)).toBe(group); + }); + + test('testExecute_Ungroup', () => { + const group = Group.create(); + const shape0 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO, parentId: group.id }); + const shape3 = Rectangle.fromRect({ rect: Rect.ZERO }); + + addShape(shape0); + addShape(group); + addShape(shape1); + addShape(shape2); + addShape(shape3); + + target.execute(new Ungroup(group)); + + expect(target.root.itemArray).toStrictEqual([shape0, shape1, shape2, shape3]); + expect(shape1.parentId).toBe(target.root.id); + expect(shape2.parentId).toBe(target.root.id); + }); + + test('testExecute_ChangeOrder', () => { + const shape1 = new MockShape(Rect.ZERO); + const shape2 = new MockShape(Rect.ZERO); + const shape3 = new MockShape(Rect.ZERO); + + addShape(shape1); + addShape(shape2); + addShape(shape3); + + target.execute(new ChangeOrder(shape3, ChangeOrderType.BACK)); + expect(target.root.itemArray).toEqual([shape1, shape2, shape3]); + + target.execute(new ChangeOrder(shape1, ChangeOrderType.BACK)); + expect(target.root.itemArray).toEqual([shape2, shape3, shape1]); + + target.execute(new ChangeOrder(shape1, ChangeOrderType.FRONT)); + expect(target.root.itemArray).toEqual([shape1, shape2, shape3]); + + target.execute(new ChangeOrder(shape3, ChangeOrderType.FRONT)); + expect(target.root.itemArray).toEqual([shape3, shape1, shape2]); + + target.execute(new ChangeOrder(shape1, ChangeOrderType.FORWARD)); + expect(target.root.itemArray).toEqual([shape1, shape3, shape2]); + + target.execute(new ChangeOrder(shape3, ChangeOrderType.BACKWARD)); + expect(target.root.itemArray).toEqual([shape1, shape2, shape3]); + }); + + test('testRecursiveVersionUpdate', () => { + const group0 = Group.create(); + addShape(group0); + const group1 = Group.create({ parentId: group0.id }); + addShape(group1); + + const rootOldVersion = target.root.versionCode; + const group0OldVersion = group0.versionCode; + const group1OldVersion = group1.versionCode; + + const group2 = Group.create({ parentId: group1.id }); + addShape(group2); + + expect(target.root.versionCode).not.toBe(rootOldVersion); + expect(group0.versionCode).not.toEqual(group0OldVersion); + expect(group1.versionCode).not.toBe(group1OldVersion); + }); +}); diff --git a/monosketch-svelte/src/lib/mono/shape/shape-manager.ts b/monosketch-svelte/src/lib/mono/shape/shape-manager.ts new file mode 100644 index 000000000..d5d3edb60 --- /dev/null +++ b/monosketch-svelte/src/lib/mono/shape/shape-manager.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2024, tuanchauict + */ + +import { Flow } from "$libs/flow"; +import { ShapeConnector } from "$mono/shape/connector/shape-connector"; +import type { AbstractShape } from "$mono/shape/shape/abstract-shape"; +import { Group, RootGroup } from "$mono/shape/shape/group"; + +/** + * An interface which defines common APIs for a command. A command must determine direct affected + * parent group via `getDirectAffectedParent`. If `getDirectAffectedParent` returns null, the + * command won't be executed. + */ +export interface Command { + getDirectAffectedParent(shapeManager: ShapeManager): Group | null; + + execute(shapeManager: ShapeManager, parent: Group): void; +} + +/** + * An interface of a shape manager which manages shapes. + */ +export class ShapeManager { + private rootInner: Group = RootGroup(null); + private allShapeMap: Map = new Map([[this.rootInner.id, this.rootInner]]); + + private rootIdMutableFlow: Flow = new Flow(this.rootInner.id); + public readonly rootIdFlow: Flow = this.rootIdMutableFlow.immutable(); + + public shapeConnectorInner: ShapeConnector = new ShapeConnector(); + + /** + * Reflect the version of the root through live data. The other components are able to observe + * this version to decide update internally. + */ + private versionMutableFlow: Flow = new Flow(this.rootInner.versionCode); + public readonly versionFlow: Flow = this.versionMutableFlow.immutable(); + + constructor() { + this.replaceRoot(this.rootInner, this.shapeConnectorInner); + } + + get root(): Group { + return this.rootInner; + } + + get shapeConnector(): ShapeConnector { + return this.shapeConnectorInner; + } + + /** + * Replace [root] with [newRoot]. + * This also wipe current stored shapes with shapes in new root. + */ + replaceRoot(newRoot: Group, newConnector: ShapeConnector) { + const currentVersion = this.rootInner.versionCode; + this.rootInner = newRoot; + this.rootIdMutableFlow.value = newRoot.id; + + this.shapeConnectorInner = newConnector; + + this.allShapeMap = this.createAllShapeMap(newRoot); + + this.versionMutableFlow.value = + // If the version of the new root is the same as the current version, we need to + // decrease the version to make sure the version is updated. + // This does not affect the version of the root, but the version of the shape manager. + currentVersion === newRoot.versionCode ? currentVersion - 1 : newRoot.versionCode; + } + + private createAllShapeMap(group: Group): Map { + const map = new Map(); + map.set(group.id, group); + this.createAllShapeMapRecursive(group, map); + return map; + } + + private createAllShapeMapRecursive(group: Group, map: Map) { + for (const shape of group.items) { + map.set(shape.id, shape); + if (shape instanceof Group) { + this.createAllShapeMapRecursive(shape, map); + } + } + } + + execute(command: Command) { + const affectedParent = command.getDirectAffectedParent(this); + if (!affectedParent) return; + + const allAncestors = this.getAllAncestors(affectedParent); + const currentVersion = affectedParent.versionCode; + + command.execute(this, affectedParent); + + if (currentVersion === affectedParent.versionCode && this.allShapeMap.has(affectedParent.id)) { + return; + } + for (const parent of allAncestors) { + parent.update(() => true); + } + this.versionMutableFlow.value = this.rootInner.versionCode; + } + + getGroup(shapeId: string | null): Group | null { + return shapeId === null ? this.rootInner : (this.allShapeMap.get(shapeId) as Group | undefined) || null; + } + + getShape(shapeId: string): AbstractShape | undefined { + return this.allShapeMap.get(shapeId); + } + + register(shape: AbstractShape) { + this.allShapeMap.set(shape.id, shape); + } + + unregister(shape: AbstractShape) { + this.allShapeMap.delete(shape.id); + } + + private getAllAncestors(group: Group): Group[] { + const result: Group[] = []; + let parent = this.allShapeMap.get(group.parentId) as Group | undefined; + while (parent) { + result.push(parent); + parent = this.allShapeMap.get(parent.parentId) as Group | undefined; + } + return result; + } + + /** + * Notifies that the information of the working project having update. + * The update is not only the shape list, but also the other information like name, etc. + * This helps the subsequence actions to keep the information up-to-date. + */ + notifyProjectUpdate() { + this.rootIdMutableFlow.value = this.rootInner.id; + } +} diff --git a/monosketch-svelte/src/lib/mono/shape/shape/group.test.ts b/monosketch-svelte/src/lib/mono/shape/shape/group.test.ts index 8c3048f77..8c960db80 100644 --- a/monosketch-svelte/src/lib/mono/shape/shape/group.test.ts +++ b/monosketch-svelte/src/lib/mono/shape/shape/group.test.ts @@ -13,7 +13,7 @@ describe('Group', () => { let target: Group; beforeEach(() => { - target = new Group(null, PARENT_ID); + target = Group.create({ parentId: PARENT_ID }); }); it('testAdd', () => { @@ -50,8 +50,8 @@ describe('Group', () => { }); it('testRemove', () => { - const shape1 = new Rectangle(Rect.ZERO); - const shape2 = new Rectangle(Rect.ZERO); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO }); target.add(shape1); target.add(shape2); @@ -66,9 +66,9 @@ describe('Group', () => { }); it('testMove_up', () => { - const shape1 = new Rectangle(Rect.ZERO); - const shape2 = new Rectangle(Rect.ZERO); - const shape3 = new Rectangle(Rect.ZERO); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape3 = Rectangle.fromRect({ rect: Rect.ZERO }); target.add(shape1); target.add(shape2); @@ -79,9 +79,9 @@ describe('Group', () => { }); it('testMove_down', () => { - const shape1 = new Rectangle(Rect.ZERO); - const shape2 = new Rectangle(Rect.ZERO); - const shape3 = new Rectangle(Rect.ZERO); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape3 = Rectangle.fromRect({ rect: Rect.ZERO }); target.add(shape1); target.add(shape2); @@ -92,9 +92,9 @@ describe('Group', () => { }); it('testMove_top', () => { - const shape1 = new Rectangle(Rect.ZERO); - const shape2 = new Rectangle(Rect.ZERO); - const shape3 = new Rectangle(Rect.ZERO); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape3 = Rectangle.fromRect({ rect: Rect.ZERO }); target.add(shape1); target.add(shape2); @@ -105,9 +105,9 @@ describe('Group', () => { }); it('testMove_bottom', () => { - const shape1 = new Rectangle(Rect.ZERO); - const shape2 = new Rectangle(Rect.ZERO); - const shape3 = new Rectangle(Rect.ZERO); + const shape1 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape2 = Rectangle.fromRect({ rect: Rect.ZERO }); + const shape3 = Rectangle.fromRect({ rect: Rect.ZERO }); target.add(shape1); target.add(shape2); diff --git a/monosketch-svelte/src/lib/mono/shape/shape/group.ts b/monosketch-svelte/src/lib/mono/shape/shape/group.ts index 79db686ee..1cce39668 100644 --- a/monosketch-svelte/src/lib/mono/shape/shape/group.ts +++ b/monosketch-svelte/src/lib/mono/shape/shape/group.ts @@ -2,6 +2,7 @@ * Copyright (c) 2024, tuanchauict */ +import type { Comparable } from "$libs/comparable"; import { Rect } from "$libs/graphics-geo/rect"; import { AddPosition, MoveActionType, QuickList } from "$mono/shape/collection/quick-list"; import { @@ -19,18 +20,30 @@ import { Text } from "$mono/shape/shape/text"; /** * A special shape which manages a collection of shapes. */ -export class Group extends AbstractShape { +export class Group extends AbstractShape implements Comparable { private quickList: QuickList = new QuickList(); items: Iterable = this.quickList; + /** + * Returns an array of items in this group. + * This creates a new array every time it is called. + */ + get itemArray(): AbstractShape[] { + return Array.from(this.items); + } + get itemCount(): number { return this.quickList.size; } - constructor(id: string | null = null, parentId: string | null = null) { + private constructor(id: string | null = null, parentId: string | null = null) { super(id, parentId); } + static create({ id = null, parentId = null }: { id?: string | null, parentId?: string | null } = {}): Group { + return new Group(id, parentId); + } + get bound(): Rect { if (this.quickList.isEmpty()) { return Rect.ZERO; @@ -109,6 +122,16 @@ export class Group extends AbstractShape { private mapItems(callback: (item: AbstractShape) => T): T[] { return Array.from(this.items).map(callback); } + + equals(other: unknown): boolean { + if (this === other) { + return true; + } + if (!(other instanceof Group)) { + return false; + } + return this.id === other.id && this.versionCode === other.versionCode && this.parentId === other.parentId; + } } /** @@ -122,7 +145,7 @@ export function RootGroup(idOrSerializableGroup: string | null | SerializableGro if (idOrSerializableGroup instanceof SerializableGroup) { return Group.fromSerializable(idOrSerializableGroup); } else { - return new Group(idOrSerializableGroup, null); + return Group.create({ id: idOrSerializableGroup, parentId: null }); } } diff --git a/monosketch-svelte/src/lib/mono/shape/shape/line.test.ts b/monosketch-svelte/src/lib/mono/shape/shape/line.test.ts index 1c973fb89..730ca5469 100644 --- a/monosketch-svelte/src/lib/mono/shape/shape/line.test.ts +++ b/monosketch-svelte/src/lib/mono/shape/shape/line.test.ts @@ -14,7 +14,7 @@ describe('Line', () => { it('testSerialization_init', () => { const startPoint = DirectedPoint.of(Direction.VERTICAL, 1, 2); const endPoint = DirectedPoint.of(Direction.HORIZONTAL, 3, 4); - const line = new Line(startPoint, endPoint, undefined, PARENT_ID); + const line = Line.fromPoints({ startPoint: startPoint, endPoint: endPoint, parentId: PARENT_ID }); const serializableLine = line.toSerializableShape(true) as SerializableLine; expect(serializableLine.startPoint).toEqual(startPoint); @@ -27,7 +27,7 @@ describe('Line', () => { it('testSerialization_moveAnchorPoints', () => { const startPoint = DirectedPoint.of(Direction.VERTICAL, 1, 2); const endPoint = DirectedPoint.of(Direction.HORIZONTAL, 3, 4); - const line = new Line(startPoint, endPoint, undefined, PARENT_ID); + const line = Line.fromPoints({ startPoint: startPoint, endPoint: endPoint, parentId: PARENT_ID }); const newStartPoint = DirectedPoint.of(Direction.HORIZONTAL, 4, 5); const newEndPoint = DirectedPoint.of(Direction.VERTICAL, 7, 8); @@ -53,7 +53,7 @@ describe('Line', () => { it('testSerialization_moveEdge', () => { const startPoint = DirectedPoint.of(Direction.VERTICAL, 1, 2); const endPoint = DirectedPoint.of(Direction.HORIZONTAL, 3, 4); - const line = new Line(startPoint, endPoint, undefined, PARENT_ID); + const line = Line.fromPoints({ startPoint: startPoint, endPoint: endPoint, parentId: PARENT_ID }); line.moveEdge(line.edges[0].id, new Point(10, 10), true); const serializableLine = line.toSerializableShape(true) as SerializableLine; @@ -67,7 +67,7 @@ describe('Line', () => { it('testSerialization_restoreInit', () => { const startPoint = DirectedPoint.of(Direction.VERTICAL, 1, 2); const endPoint = DirectedPoint.of(Direction.HORIZONTAL, 3, 4); - const line = new Line(startPoint, endPoint, undefined, PARENT_ID); + const line = Line.fromPoints({ startPoint: startPoint, endPoint: endPoint, parentId: PARENT_ID }); const serializableLine = line.toSerializableShape(true) as SerializableLine; const line2 = Line.fromSerializable(serializableLine, PARENT_ID); @@ -81,7 +81,7 @@ describe('Line', () => { it('testSerialization_restoreAfterMovingAnchorPoints', () => { const startPoint = DirectedPoint.of(Direction.VERTICAL, 1, 2); const endPoint = DirectedPoint.of(Direction.HORIZONTAL, 3, 4); - const line = new Line(startPoint, endPoint, undefined, PARENT_ID); + const line = Line.fromPoints({ startPoint: startPoint, endPoint: endPoint, parentId: PARENT_ID }); const newStartPoint = DirectedPoint.of(Direction.HORIZONTAL, 4, 5); const newEndPoint = DirectedPoint.of(Direction.VERTICAL, 7, 8); @@ -108,7 +108,7 @@ describe('Line', () => { it('testSerialization_restoreAfterMovingEdge', () => { const startPoint = DirectedPoint.of(Direction.VERTICAL, 1, 2); const endPoint = DirectedPoint.of(Direction.HORIZONTAL, 3, 4); - const line = new Line(startPoint, endPoint, undefined, PARENT_ID); + const line = Line.fromPoints({ startPoint: startPoint, endPoint: endPoint, parentId: PARENT_ID }); line.moveEdge(line.edges[0].id, new Point(10, 10), true); const serializableLine = line.toSerializableShape(true) as SerializableLine; diff --git a/monosketch-svelte/src/lib/mono/shape/shape/line.ts b/monosketch-svelte/src/lib/mono/shape/shape/line.ts index 84cadb916..d91062b1b 100644 --- a/monosketch-svelte/src/lib/mono/shape/shape/line.ts +++ b/monosketch-svelte/src/lib/mono/shape/shape/line.ts @@ -83,7 +83,7 @@ export class Line extends AbstractShape { */ private confirmedJointPoints: Point[] = []; - constructor( + private constructor( startPoint: DirectedPoint, endPoint: DirectedPoint, id: string | null = null, @@ -96,6 +96,15 @@ export class Line extends AbstractShape { this.edges = LineHelper.createEdges(this.jointPoints); } + static fromPoints({ startPoint, endPoint, id = null, parentId = null }: { + startPoint: DirectedPoint, + endPoint: DirectedPoint, + id?: string | null, + parentId?: string | null + }): Line { + return new Line(startPoint, endPoint, id, parentId); + } + get reducedJoinPoints(): Point[] { return LineHelper.reduce(this.jointPoints); } diff --git a/monosketch-svelte/src/lib/mono/shape/shape/mock-shape.ts b/monosketch-svelte/src/lib/mono/shape/shape/mock-shape.ts new file mode 100644 index 000000000..0cdc0bcee --- /dev/null +++ b/monosketch-svelte/src/lib/mono/shape/shape/mock-shape.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024, tuanchauict + */ + +import type { Rect } from "$libs/graphics-geo/rect"; +import type { AbstractSerializableShape } from "$mono/shape/serialization/shapes"; +import { AbstractShape } from "$mono/shape/shape/abstract-shape"; + +/** + * A simple shape for testing purpose + */ +export class MockShape extends AbstractShape { + private boundInner: Rect; + + constructor(rect: Rect, parentId: string | null = null) { + super(null, parentId); + this.boundInner = rect; + } + + toSerializableShape(_isIdIncluded: boolean): AbstractSerializableShape { + throw new Error('Not yet implemented'); + } + + get bound(): Rect { + return this.boundInner; + } + + set bound(value: Rect) { + this.update(() => { + const isUpdated = this.boundInner !== value; + this.boundInner = value; + return isUpdated; + }); + } +} diff --git a/monosketch-svelte/src/lib/mono/shape/shape/rectangle.ts b/monosketch-svelte/src/lib/mono/shape/shape/rectangle.ts index ac853af47..c9247b432 100644 --- a/monosketch-svelte/src/lib/mono/shape/shape/rectangle.ts +++ b/monosketch-svelte/src/lib/mono/shape/shape/rectangle.ts @@ -11,7 +11,7 @@ import { AbstractShape } from "$mono/shape/shape/abstract-shape"; export class Rectangle extends AbstractShape { private boundInner: Rect; - constructor(rect: Rect, id: string | null = null, parentId: string | null = null) { + private constructor(rect: Rect, id: string | null = null, parentId: string | null = null) { super(id, parentId); this.boundInner = rect; this.setExtra(RectangleExtra.create(ShapeExtraManager.defaultRectangleExtra)); @@ -26,7 +26,12 @@ export class Rectangle extends AbstractShape { } // Constructor that takes startPoint and endPoint - static fromPoints(startPoint: Point, endPoint: Point, id: string | null, parentId: string | null = null): Rectangle { + static fromPoints({ startPoint, endPoint, id = null, parentId = null }: { + startPoint: Point, + endPoint: Point, + id?: string | null, + parentId?: string | null + }): Rectangle { const rect = Rect.byLTRB(startPoint.left, startPoint.top, endPoint.left, endPoint.top); return new Rectangle(rect, id, parentId); } diff --git a/monosketch-svelte/src/lib/mono/shape/shape/text.test.ts b/monosketch-svelte/src/lib/mono/shape/shape/text.test.ts index d3e584a58..6475ef83e 100644 --- a/monosketch-svelte/src/lib/mono/shape/shape/text.test.ts +++ b/monosketch-svelte/src/lib/mono/shape/shape/text.test.ts @@ -12,7 +12,7 @@ describe('TextTest', () => { const PARENT_ID = '1'; it('testSerialization_init', () => { - const text = new Text(Rect.byLTWH(1, 2, 3, 4), PARENT_ID); + const text = Text.fromRect({ rect: Rect.byLTWH(1, 2, 3, 4), parentId: PARENT_ID }); const serializableText = text.toSerializableShape(true) as SerializableText; expect(text.text).toBe(serializableText.text); @@ -21,7 +21,7 @@ describe('TextTest', () => { }); it('testSerialization_updateBound', () => { - const text = new Text(Rect.byLTWH(1, 2, 3, 4), PARENT_ID); + const text = Text.fromRect({ rect: Rect.byLTWH(1, 2, 3, 4), parentId: PARENT_ID }); text.setBound(Rect.byLTWH(5, 6, 7, 8)); const serializableText = text.toSerializableShape(true) as SerializableText; @@ -31,7 +31,7 @@ describe('TextTest', () => { }); it('testSerialization_updateText', () => { - const text = new Text(Rect.byLTWH(1, 2, 3, 4), PARENT_ID); + const text = Text.fromRect({ rect: Rect.byLTWH(1, 2, 3, 4), parentId: PARENT_ID }); text.setText('Hello Hello!'); const serializableText = text.toSerializableShape(true) as SerializableText; @@ -41,7 +41,7 @@ describe('TextTest', () => { }); it('testSerialization_restore', () => { - const text = new Text(Rect.byLTWH(1, 2, 3, 4), PARENT_ID); + const text = Text.fromRect({ rect: Rect.byLTWH(1, 2, 3, 4), parentId: PARENT_ID }); text.setText('Hello Hello!'); text.setBound(Rect.byLTWH(5, 5, 2, 2)); @@ -55,7 +55,7 @@ describe('TextTest', () => { }); it('testConvertRenderableText', () => { - const target = new Text(Rect.byLTWH(0, 0, 5, 5)); + const target = Text.fromRect({ rect: Rect.byLTWH(0, 0, 5, 5) }); target.setText('0 1234 12345\n1 2 3 4 5678901 23'); target.setExtra(TextExtra.NO_BOUND); @@ -65,7 +65,7 @@ describe('TextTest', () => { }); it('testContentBound', () => { - const target = new Text(Rect.byLTWH(1, 2, 5, 6)); + const target = Text.fromRect({ rect: Rect.byLTWH(1, 2, 5, 6) }); const defaultBoundExtra = target.extra.boundExtra; diff --git a/monosketch-svelte/src/lib/mono/shape/shape/text.ts b/monosketch-svelte/src/lib/mono/shape/shape/text.ts index ff2cdf447..f1e2fcc75 100644 --- a/monosketch-svelte/src/lib/mono/shape/shape/text.ts +++ b/monosketch-svelte/src/lib/mono/shape/shape/text.ts @@ -23,7 +23,7 @@ export class Text extends AbstractShape { private isTextEditingInner: boolean = false; private renderableTextInner: RenderableText = RenderableText.EMPTY; - constructor(rect: Rect, id: string | null = null, parentId: string | null = null, isTextEditable: boolean = true) { + private constructor(rect: Rect, id: string | null = null, parentId: string | null = null, isTextEditable: boolean = true) { super(id, parentId); this.boundInner = rect; this.isTextEditableInner = isTextEditable; @@ -32,6 +32,15 @@ export class Text extends AbstractShape { this.updateRenderableText(); } + static fromRect({ rect, id = null, parentId = null, isTextEditable = true }: { + rect: Rect, + id?: string | null, + parentId?: string | null, + isTextEditable?: boolean + }): Text { + return new Text(rect, id, parentId, isTextEditable); + } + static fromPoints(startPoint: Point, endPoint: Point, id: string | null = null, parentId: string | null = null, isTextEditable: boolean): Text { const rect = Rect.byLTRB(startPoint.left, startPoint.top, endPoint.left, endPoint.top); return new Text(rect, id, parentId, isTextEditable); diff --git a/monosketch-svelte/src/lib/mono/workspace/canvas/interaction-canvas-view-controller.ts b/monosketch-svelte/src/lib/mono/workspace/canvas/interaction-canvas-view-controller.ts index 7a833bbc0..0333a238b 100644 --- a/monosketch-svelte/src/lib/mono/workspace/canvas/interaction-canvas-view-controller.ts +++ b/monosketch-svelte/src/lib/mono/workspace/canvas/interaction-canvas-view-controller.ts @@ -1,3 +1,5 @@ +import type { Point } from "$libs/graphics-geo/point"; +import { TODO } from "$libs/todo"; import { BaseCanvasViewController } from '$mono/workspace/canvas/base-canvas-controller'; import type { ThemeManager } from '$mono/ui-state-manager/theme-manager'; import { @@ -126,4 +128,10 @@ export class InteractionCanvasViewController extends BaseCanvasViewController { path.rect(leftPx, topPx, rightPx - leftPx, bottomPx - topPx); return path; }; + + getInteractionPoint = (pointPx: Point): InteractionPoint | null => { + // TODO: Implement this method + TODO("Implement this method"); + return null; + }; } diff --git a/monosketch-svelte/src/lib/mono/workspace/workspace-view-controller.ts b/monosketch-svelte/src/lib/mono/workspace/workspace-view-controller.ts index 15b676ab6..9ed18655f 100644 --- a/monosketch-svelte/src/lib/mono/workspace/workspace-view-controller.ts +++ b/monosketch-svelte/src/lib/mono/workspace/workspace-view-controller.ts @@ -1,3 +1,4 @@ +import type { Point } from "$libs/graphics-geo/point"; import { AxisCanvasViewController } from '$mono/workspace/canvas/axis-canvas-view-controller'; import { ThemeManager } from '$mono/ui-state-manager/theme-manager'; import { DrawingInfo, DrawingInfoController } from '$mono/workspace/drawing-info'; @@ -5,7 +6,7 @@ import { LifecycleOwner } from '$libs/flow'; import { WindowViewModel } from '$mono/window/window-viewmodel'; import { GridCanvasViewController } from '$mono/workspace/canvas/grid-canvas-view-controller'; import { InteractionCanvasViewController } from '$mono/workspace/canvas/interaction-canvas-view-controller'; -import type { InteractionBound } from '$mono/shape/interaction-bound'; +import type { InteractionBound, InteractionPoint } from '$mono/shape/interaction-bound'; import { MouseEventObserver } from '$mono/workspace/mouse/mouse-event-observer'; import { MousePointerType } from '$mono/workspace/mouse/mouse-pointer'; import type { AppContext } from '$app/app-context'; @@ -146,6 +147,9 @@ class CanvasViewController { this.selectionCanvasViewController.draw(); }; + getInteractionPoint = (pointPx: Point): InteractionPoint | null => + this.interactionCanvasViewController.getInteractionPoint(pointPx); + setMouseMoving = (isMouseMoving: boolean) => { this.interactionCanvasViewController.setMouseMoving(isMouseMoving); };