From 584926530c1cb0800447a98246949ecb4203e940 Mon Sep 17 00:00:00 2001 From: Stephan Egli Date: Thu, 16 Jan 2025 18:16:49 +0100 Subject: [PATCH] Switched to typescript code --- index.html | 2 +- package.json | 6 ++- src/canvasManager.ts | 65 +++++++++++++++++++++++ src/docManager.ts | 100 +++++++++++++++++++++++++++++++++++ src/main.ts | 117 +++++++++++++++++++++++++++++++++++++++++ src/shapes/Circle.ts | 34 ++++++++++++ src/shapes/Shape.ts | 25 +++++++++ src/shapes/Triangle.ts | 44 ++++++++++++++++ src/shapes/types.ts | 22 ++++++++ src/types.ts | 35 ++++++++++++ tsconfig.json | 13 +++++ 11 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 src/canvasManager.ts create mode 100644 src/docManager.ts create mode 100644 src/main.ts create mode 100644 src/shapes/Circle.ts create mode 100644 src/shapes/Shape.ts create mode 100644 src/shapes/Triangle.ts create mode 100644 src/shapes/types.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/index.html b/index.html index d8f2b3f..1781066 100644 --- a/index.html +++ b/index.html @@ -22,7 +22,7 @@ xmlns="http://www.w3.org/2000/svg" version="1.1"> - + \ No newline at end of file diff --git a/package.json b/package.json index e54ba77..6fc8e9b 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "tsc && vite build", "preview": "vite preview" }, "devDependencies": { - "vite": "^6.0.5" + "vite": "^6.0.5", + "typescript": "^5.0.0", + "@types/node": "^20.0.0" }, "dependencies": { "@automerge/automerge-repo": "^1.2.1", diff --git a/src/canvasManager.ts b/src/canvasManager.ts new file mode 100644 index 0000000..a8511c8 --- /dev/null +++ b/src/canvasManager.ts @@ -0,0 +1,65 @@ +import { Circle } from './shapes/Circle'; +import { Triangle } from './shapes/Triangle'; +import { Shape, Patch, CircleShape, TriangleShape } from './types'; + +export function createOrUpdateShapes( + container: SVGSVGElement, + shapesArray: Shape[], + patches: Patch[] = [] +): void { + if (!container) return; + + if (!patches || patches.length === 0) { + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + shapesArray.forEach(shapeData => { + const shape = createShapeInstance(shapeData); + if (shape) { + const element = shape.createSVGElement(); + container.appendChild(element); + } + }); + return; + } + + patches.forEach(patch => { + if (patch.path[0] === 'shapes') { + if (patch.action === 'put') { + const shapeIndex = parseInt(patch.path[1], 10); + const shapeData = shapesArray[shapeIndex]; + if (!shapeData) return; + + const shape = createShapeInstance(shapeData); + if (!shape) return; + + const elementId = String(shapeData.id); + let element = document.getElementById(elementId) as SVGCircleElement | SVGPolygonElement | null; + if (!element) { + element = shape.createSVGElement() as SVGCircleElement | SVGPolygonElement; + container.appendChild(element); + } else { + shape.updateAttributes(element); + } + } else if (patch.action === 'del') { + const element = document.getElementById(patch.path[1]) as SVGElement | null; + if (element) { + container.removeChild(element); + } + } + } + }); +} + +function createShapeInstance(shapeData: Shape): Circle | Triangle | null { + if (shapeData.type === "circle") { + const circleData = shapeData as CircleShape; + return new Circle(circleData.id, circleData.x, circleData.y, circleData.radius, circleData.color); + } else if (shapeData.type === "triangle") { + const triangleData = shapeData as TriangleShape; + return new Triangle(triangleData.id, triangleData.coordinates, triangleData.color); + } + + return null; +} \ No newline at end of file diff --git a/src/docManager.ts b/src/docManager.ts new file mode 100644 index 0000000..cf41781 --- /dev/null +++ b/src/docManager.ts @@ -0,0 +1,100 @@ +import { Repo, DocHandle, isValidAutomergeUrl, NetworkAdapterInterface } from "@automerge/automerge-repo"; +import { IndexedDBStorageAdapter } from '@automerge/automerge-repo-storage-indexeddb'; +import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; +import { AppDocument, CircleShape, TriangleShape } from "./types"; + +let handle: DocHandle; + +const CURRENT_SCHEMA_VERSION = 1; + +export async function initRepo(): Promise> { + const repo = new Repo({ + network: [ + new BrowserWebSocketClientAdapter("wss://automerge.rheinheim.fraction.ch") as unknown as NetworkAdapterInterface + ], + storage: new IndexedDBStorageAdapter() + }); + + const rootDocUrl = document.location.hash.substring(1); + + if (rootDocUrl && isValidAutomergeUrl(rootDocUrl)) { + handle = repo.find(rootDocUrl); + await handle.whenReady(); + + handle.change((doc: AppDocument) => { + if (!doc.schemaVersion) { + doc.schemaVersion = 1; + } + }); + } else { + handle = repo.create({ + schemaVersion: CURRENT_SCHEMA_VERSION, + shapes: [] + }); + await handle.whenReady(); + document.location.hash = handle.url; + } + + return handle; +} + +export function addCircle(x: number, y: number, radius: number): void { + handle.change((doc: AppDocument) => { + const circle: CircleShape = { + id: Date.now(), + type: "circle", + x, + y, + radius, + color: "red" + }; + doc.shapes.push(circle); + }); +} + +export function addTriangle( + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number +): void { + handle.change((doc: AppDocument) => { + const triangle: TriangleShape = { + id: Date.now(), + type: "triangle", + coordinates: [ + { x: x1, y: y1 }, + { x: x2, y: y2 }, + { x: x3, y: y3 } + ], + color: "blue" + }; + doc.shapes.push(triangle); + }); +} + +export function moveShape(shapeId: string, newX: number, newY: number): void { + handle.change((doc: AppDocument) => { + const shape = doc.shapes.find(s => s.id === Number(shapeId)); + if (!shape) return; + + if (shape.type === "circle") { + shape.x = newX; + shape.y = newY; + } else if (shape.type === "triangle") { + const center = { + x: shape.coordinates.reduce((sum, p) => sum + p.x, 0) / 3, + y: shape.coordinates.reduce((sum, p) => sum + p.y, 0) / 3 + }; + const dx = newX - center.x; + const dy = newY - center.y; + + shape.coordinates = shape.coordinates.map(p => ({ + x: p.x + dx, + y: p.y + dy + })) as [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }]; + } + }); +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..8172eec --- /dev/null +++ b/src/main.ts @@ -0,0 +1,117 @@ +import { initRepo, addCircle, addTriangle, moveShape } from "./docManager"; +import { createOrUpdateShapes } from "./canvasManager"; +import { CircleShape } from "./types"; + +interface Config { + THROTTLE_MS: number; + DEFAULT_SHAPES: { + CIRCLE: Omit; + TRIANGLE: { + x1: number; + y1: number; + x2: number; + y2: number; + x3: number; + y3: number; + }; + }; +} + +const CONFIG: Config = { + THROTTLE_MS: 16, + DEFAULT_SHAPES: { + CIRCLE: { x: 100, y: 100, radius: 30 }, + TRIANGLE: { x1: 200, y1: 200, x2: 220, y2: 220, x3: 180, y3: 220 } + } +}; + +function initDragAndDrop(container: SVGSVGElement): void { + let isDragging = false; + let selectedShapeId: string | null = null; + + const throttledMoveShape = throttle((shapeId: string, x: number, y: number) => { + moveShape(shapeId, x, y); + }, CONFIG.THROTTLE_MS); + + container.addEventListener("mousedown", (e: MouseEvent) => { + const target = e.target as SVGElement; + if (target instanceof SVGCircleElement || target instanceof SVGPolygonElement) { + isDragging = true; + selectedShapeId = target.id; + } + }); + + container.addEventListener("mousemove", (e: MouseEvent) => { + if (!isDragging || !selectedShapeId) return; + + const svgPoint = getSVGPoint(container, e.clientX, e.clientY); + throttledMoveShape(selectedShapeId, svgPoint.x, svgPoint.y); + }); + + container.addEventListener("mouseup", () => { + isDragging = false; + selectedShapeId = null; + }); +} + +function getSVGPoint(container: SVGSVGElement, x: number, y: number): DOMPoint { + const pt = container.createSVGPoint(); + pt.x = x; + pt.y = y; + const ctm = container.getScreenCTM(); + if (!ctm) { + throw new Error("Could not get screen CTM"); + } + return pt.matrixTransform(ctm.inverse()); +} + +function throttle void>(func: T, limit: number): T { + let inThrottle = false; + return function(this: any, ...args: Parameters) { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + } as T; +} + +async function initApp(): Promise { + const container = document.querySelector("#shapesCanvas") as SVGSVGElement; + if (!container) throw new Error("Canvas container not found"); + + const handle = await initRepo(); + + const doc = handle.docSync(); + if (!doc) throw new Error("Failed to sync document"); + + createOrUpdateShapes(container, doc.shapes); + + handle.on("change", (change) => { + createOrUpdateShapes(container, change.doc.shapes, change.patches); + }); + + initShapeButtons(); + initDragAndDrop(container); +} + +function initShapeButtons(): void { + const circleButton = document.querySelector("#addCircleButton") as HTMLElement; + const triangleButton = document.querySelector("#addTriangleButton") as HTMLElement; + + if (!circleButton || !triangleButton) { + throw new Error("Shape buttons not found"); + } + + circleButton.addEventListener("click", () => { + const { x, y, radius } = CONFIG.DEFAULT_SHAPES.CIRCLE; + addCircle(x, y, radius); + }); + + triangleButton.addEventListener("click", () => { + const { x1, y1, x2, y2, x3, y3 } = CONFIG.DEFAULT_SHAPES.TRIANGLE; + addTriangle(x1, y1, x2, y2, x3, y3); + }); +} + +initApp().catch(console.error); \ No newline at end of file diff --git a/src/shapes/Circle.ts b/src/shapes/Circle.ts new file mode 100644 index 0000000..6b8640c --- /dev/null +++ b/src/shapes/Circle.ts @@ -0,0 +1,34 @@ +import { Shape } from './Shape'; +import { CircleProps } from './types'; + +export class Circle extends Shape { + private x: number; + private y: number; + private radius: number; + + constructor(id: number, x: number, y: number, radius: number, color: string = "red") { + super(id, "circle", color); + this.x = x; + this.y = y; + this.radius = radius; + } + + getSVGElementType(): string { + return "circle"; + } + + updateAttributes(element: SVGElement): void { + if (!(element instanceof SVGCircleElement)) { + throw new Error("Expected SVGCircleElement"); + } + element.setAttributeNS(null, "cx", String(this.x)); + element.setAttributeNS(null, "cy", String(this.y)); + element.setAttributeNS(null, "r", String(this.radius)); + element.setAttributeNS(null, "fill", this.color); + } + + move(newX: number, newY: number): void { + this.x = newX; + this.y = newY; + } +} \ No newline at end of file diff --git a/src/shapes/Shape.ts b/src/shapes/Shape.ts new file mode 100644 index 0000000..32b6614 --- /dev/null +++ b/src/shapes/Shape.ts @@ -0,0 +1,25 @@ +const SVG_NS = "http://www.w3.org/2000/svg"; + +export abstract class Shape { + protected id: number; + protected type: string; + protected color: string; + + constructor(id: number, type: string, color: string = "black") { + this.id = id; + this.type = type; + this.color = color; + } + + createSVGElement(): SVGElement { + const element = document.createElementNS(SVG_NS, this.getSVGElementType()); + element.id = String(this.id); + this.updateAttributes(element); + return element; + } + + // Abstract methods that must be implemented by derived classes + abstract getSVGElementType(): string; + abstract updateAttributes(element: SVGElement): void; + abstract move(newX: number, newY: number): void; +} \ No newline at end of file diff --git a/src/shapes/Triangle.ts b/src/shapes/Triangle.ts new file mode 100644 index 0000000..750596a --- /dev/null +++ b/src/shapes/Triangle.ts @@ -0,0 +1,44 @@ +import { Shape } from './Shape'; +import { Coordinate, TriangleProps } from './types'; + +export class Triangle extends Shape { + private coordinates: [Coordinate, Coordinate, Coordinate]; + + constructor(id: number, coordinates: [Coordinate, Coordinate, Coordinate], color: string = "blue") { + super(id, "triangle", color); + this.coordinates = coordinates; + } + + getSVGElementType(): string { + return "polygon"; + } + + updateAttributes(element: SVGElement): void { + if (!(element instanceof SVGPolygonElement)) { + throw new Error("Expected SVGPolygonElement"); + } + const points = this.coordinates + .map(p => `${p.x},${p.y}`) + .join(" "); + element.setAttributeNS(null, "points", points); + element.setAttributeNS(null, "fill", this.color); + } + + move(newX: number, newY: number): void { + // Calculate current center + const center = { + x: this.coordinates.reduce((sum, p) => sum + p.x, 0) / 3, + y: this.coordinates.reduce((sum, p) => sum + p.y, 0) / 3 + }; + + // Calculate movement delta + const dx = newX - center.x; + const dy = newY - center.y; + + // Move all points + this.coordinates = this.coordinates.map(p => ({ + x: p.x + dx, + y: p.y + dy + })) as [Coordinate, Coordinate, Coordinate]; + } +} \ No newline at end of file diff --git a/src/shapes/types.ts b/src/shapes/types.ts new file mode 100644 index 0000000..4d62c63 --- /dev/null +++ b/src/shapes/types.ts @@ -0,0 +1,22 @@ +export interface BaseShapeProps { + id: number; + type: string; + color: string; +} + +export interface CircleProps extends BaseShapeProps { + type: 'circle'; + x: number; + y: number; + radius: number; +} + +export interface Coordinate { + x: number; + y: number; +} + +export interface TriangleProps extends BaseShapeProps { + type: 'triangle'; + coordinates: [Coordinate, Coordinate, Coordinate]; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..42b3df4 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,35 @@ +export interface ShapeBase { + id: number; + type: string; + color: string; +} + +export interface CircleShape extends ShapeBase { + type: 'circle'; + x: number; + y: number; + radius: number; +} + +export interface Coordinate { + x: number; + y: number; +} + +export interface TriangleShape extends ShapeBase { + type: 'triangle'; + coordinates: [Coordinate, Coordinate, Coordinate]; +} + +export type Shape = CircleShape | TriangleShape; + +export interface AppDocument { + schemaVersion: number; + shapes: Shape[]; +} + +export interface Patch { + action: 'put' | 'del'; + path: string[]; + value?: any; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..18e0bac --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "strict": true, + "outDir": "./dist", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} \ No newline at end of file