UNPKG

@webwriter/geometry-cloze

Version:

Create and view geometry exercises with coloring, styling and labeling options.

530 lines (501 loc) 16.3 kB
import { MathPoint } from '../helper/Calc'; import Draggable from '../elements/base/Draggable'; import Line from '../elements/Line'; import Point from '../elements/Point'; import Shape from '../elements/Shape'; import SelectionRect from '../components/SelectionRect'; import EventManager from './EventManager'; import DividerLine from '../elements/DividerLine'; import CanvasManager from './CanvasManager'; const SNAP_SPACING = 50; export default class InteractionManager extends EventManager { private _mode: InteractionMode = 'select'; protected _snapSpacing: number | null = SNAP_SPACING; private snap<Value extends number | MathPoint>(value: Value): Value { if (this._snapSpacing === null || this.keys.alt) return value; if (typeof value === 'object') { return { ...value, x: this.snap(value.x), y: this.snap(value.y) } as Value; } else return (Math.round((value as number) / this._snapSpacing) * this._snapSpacing) as Value; } public get snapping() { return this._snapSpacing !== null; } public toggleSnapping(snapping = !this.snapping) { this._snapSpacing = snapping ? SNAP_SPACING * (this.scale ?? 1) : null; // request redraw is not neccessary but requestRedraw also triggers an update (-> updates the attributes of the webcomponent) this.requestRedraw(); } private _showGrid = true; public get showGrid() { return this._showGrid; } public toggleGrid(show = !this.showGrid) { this._showGrid = show; this.requestRedraw(); } protected baseScale = 1 / 50; protected _scale: number = 1; public get scale() { return this._scale * this.baseScale; } public setScale(scale: number | null) { this._scale = scale || 1; this.requestRedraw(); } protected redraw(ctx: CanvasRenderingContext2D): void { super.redraw(ctx); this.selectionRect?.draw(ctx); this.ghostLine?.draw(ctx); this.ghostDividerLine?.draw(ctx); if (this.showGrid) { const spacing = SNAP_SPACING; ctx.strokeStyle = '#00000050'; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.beginPath(); const { width, height } = this.getCanvasDimensions(); for (let x = 0; x < width; x += spacing) { ctx.moveTo(x, 0); ctx.lineTo(x, height); } for (let y = 0; y < height; y += spacing) { ctx.moveTo(0, y); ctx.lineTo(width, y); } ctx.stroke(); } } /** * When creating a shape in create mode */ private creatingShape: { shape: Shape; lastPoint: Point } | null = null; protected handleClick( this: CanvasManager, { hit, ctrlPressed, alreadySelected, isRightClick, coords }: { coords: MathPoint; hit: Draggable | null; alreadySelected: boolean; ctrlPressed: boolean; isRightClick: boolean; } ) { coords = this.snap(coords); switch (this._mode) { case 'select': case 'divider': if (this.selectionRect) { const newSelections = this.selectionRect.getSelectedElements( this.getChildren() ); this.select(newSelections, { keepSelection: ctrlPressed }); this.selectionRect = null; this.requestRedraw(); return; } // do not unselect on context menu if (isRightClick && alreadySelected) break; // do not select certain objects if (hit && !this.canSelect(hit)) break; if (hit) { if (alreadySelected) { if (ctrlPressed) { // when clicking on a selected element while holding ctrl, we want to deselect it this.blur(hit); } else { // blur clicked element / blur everything this.blur(hit); } } else { if (ctrlPressed) { // when clicking on an unselected element while holding ctrl, we want to select it and keep the other elements selected this.select(hit, { keepSelection: true }); } else { // when clicking on an unselected element while not holding ctrl, we want to select it and deselect the other elements this.select(hit, { keepSelection: false }); } } } else { if (!ctrlPressed) this.blur(); } break; case 'create': if (!hit) { if (isRightClick) { this.ghostLine = null; this.creatingShape = null; } else { if (this.creatingShape) { // add point to creating shape const point = this.creatingShape.shape.addPoint( coords, this.creatingShape.lastPoint ); if (point) this.creatingShape.lastPoint = point; this.requestRedraw(); } else { // create new point const shape = Shape.createPoint(this, coords); this.addChild(shape); this.creatingShape = { shape, lastPoint: shape.getPoints()[0] }; } this.ghostLine = new Line(this, { start: coords, end: coords }); this.ghostLine.setStroke('#000000b0'); } } else if (hit instanceof Line) { // create new point in line const shape = ( this.getChildren((s) => s instanceof Shape) as Shape[] ).find((shape) => shape.hasChild(hit)); if (!shape) break; const point = new Point(this, coords); shape.addPoint(point); this.requestRedraw(); } else if (hit instanceof Point) { if (this.creatingShape) { const isLastPoint = hit === this.creatingShape.lastPoint; const isEndpoint = this.creatingShape.shape.isEndPoint(hit); const isChild = this.creatingShape.shape.hasChild(hit); // when clicking on other endpoint -> connect if (!isLastPoint && isEndpoint) { this.creatingShape.shape.connectPoints( hit, this.creatingShape.lastPoint ); this.ghostLine = null; this.creatingShape = null; } else if (isChild) { // when clicking on any other point of same shape -> end creating shape this.ghostLine = null; this.creatingShape = null; } else { // when clicking on an endpoint of another shape -> connect const shape = ( this.getChildren((s) => s instanceof Shape) as Shape[] ).find((shape) => shape.hasChild(hit)); if (!shape || !shape.isEndPoint(hit)) break; this.creatingShape.shape.connect( shape, this.creatingShape.lastPoint, hit ); this.ghostLine = null; this.creatingShape = null; } } } break; } } protected handleMouseMove({ current }: { current: MathPoint }) { switch (this.mode) { case 'create': if (this.ghostLine) this.ghostLine.setEnd(current); this.requestRedraw(); break; } } private ghostLine: Line | null = null; private ghostDividerLine: DividerLine | null = null; private selectionRect: SelectionRect | null = null; protected handleDragStart( this: CanvasManager, { start, hit }: { start: MathPoint; hit: Draggable | null; } ) { switch (this._mode) { case 'select': // only draw selection rect if we're not dragging an element if (!hit) { if (!this.keys.ctrl) this.blur(); this.selectionRect = new SelectionRect({ x: start.x, y: start.y }); } break; case 'create': // if dragging point -> create line starting at point if (hit && hit instanceof Point) { const shape = ( this.getChildren((s) => s instanceof Shape) as Shape[] ).find((shape) => shape.hasChild(hit)); if (!shape || !shape.isEndPoint(hit)) break; this.ghostLine = new Line(this, { start: { x: hit.x, y: hit.y }, end: start }); this.ghostLine.setStroke('#000000b0'); } break; case 'divider': if (!hit || !this.canSelect(hit)) // create line from startPoint this.ghostDividerLine = new DividerLine(this, { start: this.snap(start), end: this.snap(start) }); break; } } protected handleDragging({ start, current, element, dragStart }: { start: MathPoint; current: MathPoint; element: Draggable | null; dragStart: MathPoint & { startPositions: MathPoint[] }; }) { switch (this._mode) { case 'select': case 'divider': if (this.ghostDividerLine) { this.ghostDividerLine.setEnd(this.snap(current)); this.requestRedraw(); } if (this.selected.length > 0 && element) { // drag currently selected element const change = { x: current.x - start.x, y: current.y - start.y }; this.selected.forEach((shape, index) => { const startCoords = dragStart.startPositions[index]; const x = this.snap(startCoords.x + change.x); const y = this.snap(startCoords.y + change.y); // prevent moving element twice (move element and its parent) if (!this.selected.some((child) => child.hasChild(shape))) shape.move({ x, y, relative: false }); }); this.requestRedraw(); } else { // draw selection rect if (this.selectionRect) { this.selectionRect.setSecondCoords(current); this.requestRedraw(); } } break; case 'create': if (this.ghostLine) { this.ghostLine.setEnd(current); this.requestRedraw(); } break; } } protected handleDragEnd( this: CanvasManager, { to, element }: { from: MathPoint; to: MathPoint; element: Draggable | null; } ) { switch (this._mode) { case 'select': if (this.selectionRect) { const newSelections = this.selectionRect.getSelectedElements( this.getChildren() ); this.select(newSelections, { keepSelection: this.keys.ctrl }); this.selectionRect = null; } if (element) { // snap to grid element.move({ relative: false, x: this.snap(element.x), y: this.snap(element.y) }); } break; case 'create': if (this.ghostLine && element) { // create new line from point -> check if lands on point -> end on point + merge shapes const hitElement = this.getElementAt(to); if (hitElement instanceof Point) { const end = hitElement; const start = element; const children = this.getChildren( (s) => s instanceof Shape ) as Shape[]; const shape1 = children.find((shape) => shape.hasChild(start)); const shape2 = end instanceof Shape ? end : children.find((shape) => shape.hasChild(end)); if (!shape1 || !shape2) { console.error("Couldn't find shapes for merging"); } else shape1.connect(shape2, element as Point, end); } } this.ghostLine = null; break; case 'divider': if (this.ghostDividerLine) { this.addChild(this.ghostDividerLine); this.ghostDividerLine = null; } } this.selectionRect = null; this.requestRedraw(); } protected upadateCursor(coords: MathPoint): CSSStyleDeclaration['cursor'] { // check if we're hovering over a draggable element and change cursor accordingly const hit = this.getElementAt(coords); const canSelect = Boolean(hit && this.canSelect(hit)); switch (this.mode) { case 'select': return hit && canSelect ? 'grab' : 'default'; case 'create': if (hit && hit instanceof Point) { const shape = ( this.getChildren((s) => s instanceof Shape) as Shape[] ).find((shape) => shape.hasChild(hit)); const isEndPoint = shape?.isEndPoint(hit); return isEndPoint ? 'crosshair' : 'default'; } else return 'pointer'; case 'divider': return canSelect ? 'grab' : 'pointer'; default: return 'default'; } } protected handleKeyboardEvent(key: string) { // switch modes if (['d', 's', 'c'].some((k) => key.toLowerCase() === k)) { switch (key.toLowerCase()) { case 'c': this.mode = 'create'; break; case 's': this.mode = 'select'; break; case 'd': this.mode = 'divider'; break; } return; } // delete selected elements switch (key) { case 'Delete': case 'Backspace': this.selected.forEach((shape) => shape.delete()); this.blur(); break; } // mode specific keyboard events switch (this.mode) { case 'select': switch (key) { case 'Escape': this.blur(); break; case 'a': case 'A': if (this.keys.ctrl) { const toSelect = this.getChildren((child) => this.canSelect(child) ); this.select(toSelect); } } break; case 'create': switch (key) { case 'Escape': this.ghostLine = null; this.creatingShape = null; this.mode = 'select'; this.requestRedraw(); break; } break; case 'divider': switch (key) { case 'Escape': this.ghostDividerLine = null; this.mode = 'select'; this.requestRedraw(); break; } break; } } public canSelect(element: Draggable): boolean { if (this.mode === 'divider') { if (element instanceof DividerLine) return true; if ( this.getChildren((child) => child instanceof DividerLine).some((line) => line.hasChild(element) ) ) return true; return false; } return true; } public get mode() { return this._mode; } public set mode(mode: typeof this._mode) { this._mode = mode; this.ghostLine = null; this.creatingShape = null; this.ghostDividerLine = null; if (mode === 'divider') this.blur(); else { this.getChildren((child) => child instanceof DividerLine).forEach( (line) => this.blur(line) ); } this.requestRedraw(); } public export() { return { ...super.export(), mode: this.mode, showGrid: this.showGrid, snapping: this.snapping }; } public import(data: Partial<ReturnType<this['export']>>) { super.import(data); if (data.mode) this.mode = data.mode; if (data.showGrid !== undefined) this._showGrid = data.showGrid; if (data.snapping !== undefined) this.toggleSnapping(data.snapping); } }