import type { HousePlannerCanvas } from './canvas'; import { HousePlannerCanvasHistory } from './history'; import { BezierSegment, History, Layer, LayerObject, Line, LineSegment, ToolEvent, Vec2, } from './interfaces'; import { ToolType, SubToolType, BezierControl, LineControl } from './types'; import { vec2Add, vec2Equals, vec2InCircle, vec2Inverse, vec2Snap, vec2Sub, } from './utils'; export class HousePlannerCanvasTools { public selectedLayer?: Layer; public selectedObjects: LayerObject[] = []; public mousePosition: Vec2 = [0, 0]; public mousePositionSnapped: Vec2 = [0, 0]; public gridSnap = true; public gridSnapScale = 8; public tool: ToolType = 'line'; public subTool: SubToolType = 'line'; public history = new HousePlannerCanvasHistory(); public lastStrokeWidth = 16; public lastColor = '#000'; private dragging = false; private pinching = false; private lastPinchLength = 0; private moved = false; private holdShift = false; private selectError = 16; private bezierControls: BezierSegment[] = []; private lineControls: LineSegment[] = []; private handlingBezier: BezierControl | null = null; private handlingLine: LineControl | null = null; private drawingLine: Line | null = null; private clickedOn: LayerObject | null = null; private movingObject = false; constructor(public manager: HousePlannerCanvas) {} get canvas() { return this.manager.canvas; } get ctx() { return this.manager.ctx; } isSelected(object: LayerObject) { return this.selectedObjects.indexOf(object) > -1; } selectLayer(layer: Layer) { if (this.selectedLayer) { this.selectedLayer.active = false; for (const item of this.selectedLayer.contents) { item.selected = false; } this.selectedObjects.length = 0; } this.selectedLayer = layer; this.selectedLayer.active = true; } selectObject(object?: LayerObject | null, add = false) { if (!object) { if (!add) { this.selectedObjects.forEach((object) => { object.selected = false; }); this.selectedObjects.length = 0; } this.manager.draw(); return; } if (this.selectedObjects.includes(object)) { // Unselect in multi-select if (add) { const foundAt = this.selectedObjects.indexOf(object); object.selected = false; this.selectedObjects.splice(foundAt, 1); this.manager.draw(); } return; } // Select in multi-select if (add) { object.selected = true; this.selectedObjects.push(object); this.manager.draw(); return; } this.selectedObjects.forEach((obj) => { obj.selected = false; }); object.selected = true; this.selectedObjects = [object]; this.manager.draw(); } getMousedObject() { if (!this.selectedLayer?.visible) return null; const [x, y] = this.mousePosition; for (const object of this.selectedLayer.contents) { if ((object as Line).segments) { const moused = this.manager.isOnLine( object as Line, x, y, this.selectError ); if (moused) return object; } // TODO: other kinds of objects } return null; } getMousedLineSegment(line: Line) { if (!this.selectedLayer?.visible) return null; const [x, y] = this.mousePosition; let lastSegment = null; for (const segment of line.segments) { const lastPoint = lastSegment ? lastSegment.end : (segment.start as Vec2); if ( this.manager.isOnSegment( line, segment, lastPoint, x, y, this.selectError ) ) { return segment; } lastSegment = segment; } return null; } drawHighlights() { for (const object of this.selectedObjects) { const line = object as Line; if (line.segments) { const path = this.manager.makeLinePath(line); this.manager.setupLine(line, this.selectError, '#00ddff55'); this.ctx.stroke(path); } // TODO: other kinds of objects } } drawBezierControls(bezier: BezierSegment, previousEnd: Vec2) { this.bezierControls.push(bezier); const [cp1x, cp1y] = bezier.startControl; const [cp2x, cp2y] = bezier.endControl; const [endx, endy] = bezier.end; const [prevx, prevy] = previousEnd; this.ctx.fillStyle = '#00ddffaa'; this.ctx.strokeStyle = '#00ddffaa'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.arc(cp1x, cp1y, this.selectError / 2, 0, 2 * Math.PI); this.ctx.fill(); this.ctx.beginPath(); this.ctx.arc(cp2x, cp2y, this.selectError / 2, 0, 2 * Math.PI); this.ctx.fill(); this.ctx.beginPath(); this.ctx.moveTo(cp1x, cp1y); this.ctx.lineTo(prevx, prevy); this.ctx.stroke(); this.ctx.beginPath(); this.ctx.moveTo(cp2x, cp2y); this.ctx.lineTo(endx, endy); this.ctx.stroke(); } drawLineControls(line: LineSegment, previousEnd: Vec2) { this.lineControls.push(line); const [endx, endy] = line.end; this.ctx.fillStyle = '#00ddffaa'; this.ctx.beginPath(); this.ctx.arc(endx, endy, this.selectError / 2, 0, 2 * Math.PI); this.ctx.fill(); if (line.start) { const [startx, starty] = line.start; this.ctx.beginPath(); this.ctx.arc(startx, starty, this.selectError / 2, 0, 2 * Math.PI); this.ctx.fill(); } } drawControls() { this.bezierControls.length = 0; this.lineControls.length = 0; for (const object of this.selectedObjects) { const line = object as Line; if (line.segments && line.render) { let lastSegment = null; for (const segment of line.segments) { const bezier = segment as BezierSegment; const previousPoint = lastSegment ? lastSegment.end : segment.start!; if (bezier.startControl && bezier.endControl) { this.drawBezierControls(bezier, previousPoint); } this.drawLineControls(segment, previousPoint); lastSegment = segment; } } // TODO: other kinds of objects } if (this.tool === 'line') { if (this.selectedObjects.length) return; const [mx, my] = this.mousePositionSnapped; this.ctx.fillStyle = '#00ddffaa'; this.ctx.beginPath(); this.ctx.arc(mx, my, this.selectError / 2, 0, 2 * Math.PI); this.ctx.fill(); } } cleanUp() {} setTool(tool: typeof this.tool, subTool?: typeof this.subTool) { this.tool = tool; if (subTool !== undefined) this.subTool = subTool; this.canvas.dispatchEvent( new CustomEvent('hpc:tool', { detail: { primary: this.tool, secondary: this.subTool }, }) ); } onMouseMove(e: MouseEvent) { this.dragEvent(e.clientX, e.clientY); } onMouseDown(e: MouseEvent) { this.mousePosition = [e.clientX, e.clientY]; this.mousePositionSnapped = this.gridSnap ? vec2Snap(this.mousePosition, this.gridSnapScale) : this.mousePosition; this.dragging = true; this.moved = false; this.pointerDown(); } onMouseUp(e: MouseEvent) { this.dragging = false; this.pointerUp(); } onTouchMove(ev: TouchEvent) { ev.preventDefault(); if (ev.touches.length === 2 && this.pinching) { const pinchLength = Math.hypot( ev.touches[0].pageX - ev.touches[1].pageX, ev.touches[0].pageY - ev.touches[1].pageY ); // TODO: zoom // if (this.lastPinchLength) { // const delta = pinchLength / this.lastPinchLength; // const scaleX = (ev.touches[0].clientX - this._posx) / this._zoom; // const scaleY = (ev.touches[0].clientY - this._posy) / this._zoom; // delta > 0 ? (this._zoom *= delta) : (this._zoom /= delta); // this._zoom = clamp(this._zoom, 1, 100); // this._posx = ev.touches[0].clientX - scaleX * this._zoom; // this._posy = ev.touches[0].clientY - scaleY * this._zoom; // } // this.lastPinchLength = pinchLength; } this.dragEvent(ev.touches[0].clientX, ev.touches[0].clientY); } onTouchStart(e: TouchEvent) { e.preventDefault(); const touch = e.touches[0] || e.changedTouches[0]; this.mousePosition = [touch.pageX, touch.pageY]; this.mousePositionSnapped = this.gridSnap ? vec2Snap(this.mousePosition, this.gridSnapScale) : this.mousePosition; this.dragging = true; this.moved = false; if (e.touches.length === 2) { this.pinching = true; } this.pointerDown(); } onTouchEnd(e: TouchEvent) { this.pinching = false; this.lastPinchLength = 0; if (!e.touches?.length) { this.dragging = false; } this.pointerUp(); } onMouseWheel(e: WheelEvent) { // TODO: zoom // ev.preventDefault(); // this._mousex = ev.clientX; // this._mousey = ev.clientY; // const scaleX = (ev.clientX - this._posx) / this._zoom; // const scaleY = (ev.clientY - this._posy) / this._zoom; // ev.deltaY < 0 ? (this._zoom *= 1.2) : (this._zoom /= 1.2); // this._zoom = clamp(this._zoom, this._minZoom, this._maxZoom); // this._posx = ev.clientX - scaleX * this._zoom; // this._posy = ev.clientY - scaleY * this._zoom; // const realSize = this._zoom * this._size; // this._posx = clamp(this._posx, this._cursorx - realSize, this._cursorx); // this._posy = clamp(this._posy, this._cursory - realSize, this._cursory); } onPointerLeave() { this.dragging = false; } onKeyDown(e: KeyboardEvent) { if (e.key === 'z' && e.ctrlKey) { this.history.undo(); this.manager.draw(); this.canvas.dispatchEvent( new CustomEvent('hpc:undo', { detail: 'keyboard', }) ); this.canvas.dispatchEvent( new CustomEvent('hpc:update', { detail: { event: 'undo', }, }) ); } if (e.key === 'y' && e.ctrlKey) { this.history.redo(); this.manager.draw(); this.canvas.dispatchEvent( new CustomEvent('hpc:redo', { detail: 'keyboard', }) ); this.canvas.dispatchEvent( new CustomEvent('hpc:update', { detail: { event: 'redo' }, }) ); } if (e.key === 'Shift') { this.holdShift = true; } } onKeyUp(e: KeyboardEvent) { if (e.key === 'Enter') { if (this.drawingLine && this.selectedLayer) { if (this.subTool === 'room') { this.drawingLine.closed = true; } this.drawingLine.segments.splice( this.drawingLine.segments.length - 1, 1 ); this.history.appendToHistory([ { object: this.selectedLayer, property: 'contents', value: [...this.selectedLayer.contents].filter( (item) => item !== this.drawingLine ), } as History, { object: this, property: 'selectedObjects', value: [], }, ]); this.canvas.dispatchEvent( new CustomEvent('hpc:newobject', { detail: this.drawingLine, }) ); this.canvas.dispatchEvent( new CustomEvent('hpc:update', { detail: { event: 'newobject', object: this.drawingLine }, }) ); this.drawingLine = null; this.manager.draw(); } } if (e.key === 'Escape') { if (this.selectedObjects.length === 0 && this.tool && !this.drawingLine) { this.setTool(null); } this.selectObject(null); if (this.drawingLine && this.selectedLayer) { const indexOf = this.selectedLayer.contents.indexOf(this.drawingLine); if (indexOf > -1) { this.selectedLayer.contents.splice(indexOf, 1); } this.drawingLine = null; this.manager.draw(); } } if (e.key === 'Shift') { this.holdShift = false; } if (e.key === 'l') { this.setTool('line'); } if (e.key === 'Delete' || e.key === 'x') { this.deleteSelection(); } } deleteSelection() { if (!this.selectedObjects.length || !this.selectedLayer) return; this.history.appendToHistory([ { object: this.selectedLayer, property: 'contents', value: [...this.selectedLayer.contents], } as History, ]); this.selectedLayer.contents = this.selectedLayer.contents.filter( (item) => this.selectedObjects.indexOf(item) === -1 ); this.selectedObjects.length = 0; this.manager.draw(); this.canvas.dispatchEvent( new CustomEvent('hpc:update', { detail: { event: 'delete' }, }) ); } private grabbedBezierControl() { let clickedOnControl: BezierControl | null = null; for (const bezier of this.bezierControls) { for (const control of ['startControl', 'endControl']) { const asType = control as 'startControl' | 'endControl'; if ( vec2InCircle(bezier[asType], this.mousePosition, this.selectError / 2) ) { clickedOnControl = [bezier, asType, bezier[asType]]; } } } return clickedOnControl; } private grabbedLineControl() { let clickedOnControl: LineControl | null = null; for (const line of this.lineControls) { for (const control of ['start', 'end']) { const asType = control as 'start' | 'end'; if ( line[asType] && vec2InCircle( line[asType] as Vec2, this.mousePosition, this.selectError / 2 ) ) { clickedOnControl = [line, asType, line[asType] as Vec2]; } } } return clickedOnControl; } private translate(offset: Vec2) { for (const object of this.selectedObjects) { if ((object as Line).segments) { for (const segment of (object as Line).segments) { if ((segment as BezierSegment).startControl) { const bezier = segment as BezierSegment; bezier.startControl = vec2Add(bezier.startControl, offset); bezier.endControl = vec2Add(bezier.endControl, offset); } if (segment.start) { segment.start = vec2Add(segment.start, offset); } segment.end = vec2Add(segment.end, offset); } } } this.manager.draw(); } private pointerDown() { if (this.drawingLine) return; this.clickedOn = this.getMousedObject(); const bezierControl = this.grabbedBezierControl(); const lineControl = this.grabbedLineControl(); if (bezierControl) { this.handlingBezier = bezierControl; } else if (lineControl) { this.handlingLine = lineControl; } } private pointerUp() { if (!this.moved && !this.handlingBezier && !this.handlingLine) { if (this.tool === 'line') { this.startLine(); } else if (!this.drawingLine) { this.pick(); } } if (this.handlingBezier) { this.history.appendToHistory([ { object: this.handlingBezier[0], property: this.handlingBezier[1], value: this.handlingBezier[2], }, ]); } if (this.handlingLine) { this.history.appendToHistory([ { object: this.handlingLine[0], property: this.handlingLine[1], value: this.handlingLine[2], }, ]); } if (this.movingObject || this.handlingLine || this.handlingBezier) { this.canvas.dispatchEvent( new CustomEvent('hpc:update', { detail: { event: 'line-move', object: this.handlingLine || this.handlingBezier || this.clickedOn, }, }) ); } this.handlingBezier = null; this.handlingLine = null; this.clickedOn = null; this.movingObject = false; } private startLine() { if (!this.selectedLayer?.visible) return; if (this.drawingLine) { if ( vec2Equals( this.mousePositionSnapped, this.drawingLine.segments[0].start as Vec2 ) || this.drawingLine.type === 'curve' ) { if (this.drawingLine.type !== 'curve') { this.drawingLine.segments.splice( this.drawingLine.segments.length - 1, 1 ); } this.history.appendToHistory([ { object: this.selectedLayer, property: 'contents', value: [...this.selectedLayer.contents].filter( (item) => item !== this.drawingLine ), } as History, { object: this, property: 'selectedObjects', value: [], }, ]); this.drawingLine.closed = true; this.canvas.dispatchEvent( new CustomEvent('hpc:newobject', { detail: this.drawingLine, }) ); this.canvas.dispatchEvent( new CustomEvent('hpc:update', { detail: { event: 'newobject', object: this.drawingLine }, }) ); this.drawingLine = null; return; } this.drawingLine.segments.push({ end: [...this.mousePositionSnapped], }); return; } const newLineObject: Line = { name: 'New Line', type: this.subTool!, visible: true, selected: false, closed: false, color: this.lastColor, width: this.subTool === 'curve' ? 2 : this.lastStrokeWidth, segments: [ { start: [...this.mousePositionSnapped], end: [...this.mousePositionSnapped], }, ], }; this.drawingLine = newLineObject; this.selectedLayer.contents.unshift(newLineObject); this.selectObject(this.drawingLine); this.canvas.dispatchEvent( new CustomEvent('hpc:startdrawing', { detail: newLineObject, }) ); } private pick() { if (this.clickedOn && this.isSelected(this.clickedOn)) { // Pick line segment if ((this.clickedOn as Line).segments) { const segment = this.getMousedLineSegment(this.clickedOn as Line); } } this.selectObject(this.clickedOn, this.holdShift); } private dragEvent(x: number, y: number) { this.moved = true; const currentPos = this.gridSnap ? vec2Snap(this.mousePosition, this.gridSnapScale) : this.mousePosition; const rect = this.manager.canvas.getBoundingClientRect(); this.mousePosition = [x - rect.left, y - rect.top]; this.mousePositionSnapped = this.gridSnap ? vec2Snap(this.mousePosition, this.gridSnapScale) : this.mousePosition; let offset = vec2Sub(currentPos, this.mousePositionSnapped); if (!this.selectedLayer) return; if (this.tool) { if (this.drawingLine) { const lastSegment = this.drawingLine.segments.at(-1); if (lastSegment) { lastSegment.end = [...this.mousePositionSnapped]; } } this.manager.draw(); } if (this.drawingLine) { return; } if (this.handlingBezier) { const [segment, property] = this.handlingBezier; segment[property] = [...this.mousePositionSnapped]; this.manager.draw(); } else if (this.handlingLine) { const [segment, property] = this.handlingLine; segment[property] = [...this.mousePositionSnapped]; this.manager.draw(); } else if (this.clickedOn) { if (!this.movingObject) { // TODO: optimize history storage this.history.appendToHistory([ { object: this.selectedLayer, property: 'contents', value: JSON.parse(JSON.stringify(this.selectedLayer['contents'])), }, ]); this.movingObject = true; } this.translate(vec2Inverse(offset)); } } private selectedEvent() { this.canvas.dispatchEvent( new CustomEvent('hpc:selectionchange', { detail: this.selectedObjects, }) ); } }