Switched to typescript code
This commit is contained in:
parent
bfe44bec1e
commit
584926530c
@ -22,7 +22,7 @@
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.1">
|
||||
</svg>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -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",
|
||||
|
||||
65
src/canvasManager.ts
Normal file
65
src/canvasManager.ts
Normal file
@ -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;
|
||||
}
|
||||
100
src/docManager.ts
Normal file
100
src/docManager.ts
Normal file
@ -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<AppDocument>;
|
||||
|
||||
const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
export async function initRepo(): Promise<DocHandle<AppDocument>> {
|
||||
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<AppDocument>(rootDocUrl);
|
||||
await handle.whenReady();
|
||||
|
||||
handle.change((doc: AppDocument) => {
|
||||
if (!doc.schemaVersion) {
|
||||
doc.schemaVersion = 1;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
handle = repo.create<AppDocument>({
|
||||
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 }];
|
||||
}
|
||||
});
|
||||
}
|
||||
117
src/main.ts
Normal file
117
src/main.ts
Normal file
@ -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<CircleShape, 'id' | 'type' | 'color'>;
|
||||
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<T extends (...args: any[]) => void>(func: T, limit: number): T {
|
||||
let inThrottle = false;
|
||||
return function(this: any, ...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
} as T;
|
||||
}
|
||||
|
||||
async function initApp(): Promise<void> {
|
||||
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);
|
||||
34
src/shapes/Circle.ts
Normal file
34
src/shapes/Circle.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
25
src/shapes/Shape.ts
Normal file
25
src/shapes/Shape.ts
Normal file
@ -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;
|
||||
}
|
||||
44
src/shapes/Triangle.ts
Normal file
44
src/shapes/Triangle.ts
Normal file
@ -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];
|
||||
}
|
||||
}
|
||||
22
src/shapes/types.ts
Normal file
22
src/shapes/types.ts
Normal file
@ -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];
|
||||
}
|
||||
35
src/types.ts
Normal file
35
src/types.ts
Normal file
@ -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;
|
||||
}
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user