diff --git a/app/app.py b/app/app.py index 1de0684..050637a 100644 --- a/app/app.py +++ b/app/app.py @@ -2,7 +2,6 @@ import os import traceback from time import strftime -from urllib.parse import urlparse from dotenv import load_dotenv from flask import (Flask, flash, jsonify, make_response, redirect, @@ -12,7 +11,7 @@ from werkzeug.exceptions import HTTPException from app.auth.auth_utils import UserManager -from app.utils import is_safe_url, limiter +from app.utils import limiter csrf = CSRFProtect() login_manager = LoginManager() diff --git a/app/static/js/Canvas.js b/app/static/js/Canvas.js index 576a275..c5eb2ec 100644 --- a/app/static/js/Canvas.js +++ b/app/static/js/Canvas.js @@ -54,10 +54,12 @@ class Canvas { // Drawing state this.isDrawing = false; - this.lastX = 0; - this.lastY = 0; this.previewShape = null; // Add preview shape state + // Copy of the field and unselected strokes reused while dragging + this.gestureLayer = null; + this.gestureLayerValid = false; + // Pan and zoom state this.isPanning = false; this.startPanX = 0; @@ -114,18 +116,8 @@ class Canvas { }); // Perfect freehand settings - this.pressure = 0.5; this.thinning = 0.5; this.smoothing = 0.5; - this.streamline = 0.5; - this.points = []; - - // Stroke settings - this.minWidth = 0.5; - this.maxWidth = 4; - this.lastVelocity = 0; - this.lastWidth = 0; - this.velocityFilterWeight = 0.7; // Auto-save interval (every 30 seconds) this.autoSaveInterval = setInterval(() => this.autoSave(), 30000); @@ -412,7 +404,64 @@ class Canvas { } redrawCanvas() { - // Ensure canvas context is valid + this.gestureLayerValid = false; + if (!this.ensureContext()) { + return; + } + this.drawScene(); + this.drawOverlay(); + } + + // Redraw during a drag. The field and unselected strokes do not change + // until the gesture ends, so they are drawn once into an offscreen copy and + // blitted on later moves; only the selection, the stroke being drawn and + // previews are redrawn. Any full redrawCanvas() (undo, pan, zoom, load...) + // discards the copy. + redrawGesture() { + if (!this.ensureContext()) { + return; + } + + if (!this.gestureLayerValid) { + this.renderGestureLayer(); + } + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.drawImage(this.gestureLayer, 0, 0); + this.applyViewTransform(); + this.drawOverlay(); + } + + // Draw the scene into the offscreen copy. It is drawn there directly rather + // than copied from the board: a desynchronized canvas is read when the copy + // is flushed, which can already include the overlay. + renderGestureLayer() { + this.gestureLayer ||= document.createElement("canvas"); + const layer = this.gestureLayer; + if (layer.width !== this.canvas.width) { + layer.width = this.canvas.width; + } + if (layer.height !== this.canvas.height) { + layer.height = this.canvas.height; + } + + const board = this.ctx; + const layerCtx = layer.getContext("2d"); + // Shapes inherit line caps and joins from earlier drawing; match the board + layerCtx.lineCap = board.lineCap; + layerCtx.lineJoin = board.lineJoin; + this.ctx = layerCtx; + try { + this.drawScene(); + } finally { + this.ctx = board; + } + board.lineCap = layerCtx.lineCap; + board.lineJoin = layerCtx.lineJoin; + this.gestureLayerValid = true; + } + + // Ensure canvas context is valid + ensureContext() { if (!this.ctx) { this.ctx = this.canvas.getContext("2d", { desynchronized: true, @@ -420,45 +469,22 @@ class Canvas { }); if (!this.ctx) { this.showStatus("Error: Could not restore canvas context"); - return; + return false; } } + return true; + } + // Background, field image and unselected strokes; leaves the view + // transform applied for drawOverlay() + drawScene() { this.ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform // Set white background this.ctx.fillStyle = "#ffffff"; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); - // Apply transformations with safety checks - const safeOffsetX = isFinite(this.offsetX) ? this.offsetX : 0; - const safeOffsetY = isFinite(this.offsetY) ? this.offsetY : 0; - this.ctx.translate(safeOffsetX, safeOffsetY); - - // Apply scale for drawing with safety check - const safeScale = Math.min( - Math.max(this.scale, this.MIN_SCALE), - this.MAX_SCALE, - ); - if (this.scale !== safeScale) { - this.scale = safeScale; - this.showStatus(`Scale limited to ${this.scale.toFixed(2)}`); - } - this.ctx.scale(this.scale, this.scale); - - // Apply flip transformations - if (this.flipX) { - this.ctx.scale(-1, 1); - } - if (this.flipY) { - this.ctx.scale(1, -1); - } - - // Apply rotation - if (this.rotation !== 0) { - const rotationRadians = (this.rotation * Math.PI) / 180; - this.ctx.rotate(rotationRadians); - } + this.applyViewTransform(); // Draw the field image first if loaded if (this.backgroundLoaded) { @@ -495,7 +521,43 @@ class Canvas { } } }); + } + + // Pan, zoom, flip and rotation, applied on top of the identity transform + applyViewTransform() { + // Apply transformations with safety checks + const safeOffsetX = isFinite(this.offsetX) ? this.offsetX : 0; + const safeOffsetY = isFinite(this.offsetY) ? this.offsetY : 0; + this.ctx.translate(safeOffsetX, safeOffsetY); + + // Apply scale for drawing with safety check + const safeScale = Math.min( + Math.max(this.scale, this.MIN_SCALE), + this.MAX_SCALE, + ); + if (this.scale !== safeScale) { + this.scale = safeScale; + this.showStatus(`Scale limited to ${this.scale.toFixed(2)}`); + } + this.ctx.scale(this.scale, this.scale); + // Apply flip transformations + if (this.flipX) { + this.ctx.scale(-1, 1); + } + if (this.flipY) { + this.ctx.scale(1, -1); + } + + // Apply rotation + if (this.rotation !== 0) { + const rotationRadians = (this.rotation * Math.PI) / 180; + this.ctx.rotate(rotationRadians); + } + } + + // Selected strokes, the stroke being drawn, shape preview and selection box + drawOverlay() { // Draw selected strokes with highlight this.ctx.save(); this.ctx.strokeStyle = "#0066ff"; @@ -1044,742 +1106,498 @@ class Canvas { // Event binding bindEvents() { - // Resize handler window.addEventListener("resize", () => this.resizeCanvas()); + this.container.addEventListener("wheel", (e) => this.onWheel(e)); + this.canvas.addEventListener("mousedown", (e) => this.onMouseDown(e)); + this.canvas.addEventListener("mousemove", (e) => this.onMouseMove(e)); + this.canvas.addEventListener("mouseup", () => this.onMouseUp()); + this.canvas.addEventListener("mouseleave", () => this.onMouseLeave()); + this.canvas.addEventListener("touchstart", (e) => this.onTouchStart(e)); + this.canvas.addEventListener("touchmove", (e) => this.onTouchMove(e)); + this.canvas.addEventListener("touchend", (e) => this.onTouchEnd(e)); + window.addEventListener("keydown", (e) => this.onKeyDown(e)); + } - // Mouse wheel for zooming - this.container.addEventListener("wheel", (e) => { - e.preventDefault(); + // Mouse wheel zooms around the pointer + onWheel(e) { + e.preventDefault(); - const mouseX = e.clientX; - const mouseY = e.clientY; + const mouseX = e.clientX; + const mouseY = e.clientY; - // Calculate position before zoom - const pointBeforeZoomX = (mouseX - this.offsetX) / this.scale; - const pointBeforeZoomY = (mouseY - this.offsetY) / this.scale; + // Calculate position before zoom + const pointBeforeZoomX = (mouseX - this.offsetX) / this.scale; + const pointBeforeZoomY = (mouseY - this.offsetY) / this.scale; - // Adjust zoom level - const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; - this.scale *= zoomFactor; + // Adjust zoom level + const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + this.scale *= zoomFactor; - // Limit zoom - this.scale = Math.min(Math.max(0.1, this.scale), 10); + // Limit zoom + this.scale = Math.min(Math.max(0.1, this.scale), 10); - // Calculate position after zoom - const pointAfterZoomX = (mouseX - this.offsetX) / this.scale; - const pointAfterZoomY = (mouseY - this.offsetY) / this.scale; + // Calculate position after zoom + const pointAfterZoomX = (mouseX - this.offsetX) / this.scale; + const pointAfterZoomY = (mouseY - this.offsetY) / this.scale; - // Adjust offset to keep mouse position fixed - this.offsetX += (pointAfterZoomX - pointBeforeZoomX) * this.scale; - this.offsetY += (pointAfterZoomY - pointBeforeZoomY) * this.scale; + // Adjust offset to keep mouse position fixed + this.offsetX += (pointAfterZoomX - pointBeforeZoomX) * this.scale; + this.offsetY += (pointAfterZoomY - pointBeforeZoomY) * this.scale; - // Apply pan limits - this.applyPanLimits(); + this.applyPanLimits(); + this.redrawCanvas(); + } - // Redraw - this.redrawCanvas(); + // Mouse: shift or middle-button drag pans (also in readonly mode); the left + // button uses the current tool. + onMouseDown(e) { + if (e.shiftKey || e.button === 1) { + this.startPan(e.clientX, e.clientY); + this.canvas.style.cursor = "grabbing"; + } else if (!this.readonly) { + const pos = this.getTransformedPosition(e.clientX, e.clientY); + this.pointerDown(pos, e.ctrlKey || e.metaKey); + } + } - // Show zoom level - // this.showStatus(`Zoom: ${Math.round(this.scale * 100)}%`); - }); + onMouseMove(e) { + if (this.isPanning) { + this.panTo(e.clientX, e.clientY); + return; + } + // Hovering does nothing unless the select tool shows handle cursors + const idle = + this.startX === null && + !this.isDrawing && + !this.isSelecting && + !this.moveSelection.active && + !this.resizeHandles.active; + if (this.readonly || (idle && this.currentTool !== "select")) { + return; + } - // Mouse events for drawing and panning - this.canvas.addEventListener("mousedown", (e) => { - if (this.readonly && !e.shiftKey && e.button !== 1) { - return; - } // Only allow panning in readonly mode - - this.lastEvent = e; - if (e.shiftKey || e.button === 1) { - this.isPanning = true; - this.startPanX = e.clientX - this.offsetX; - this.startPanY = e.clientY - this.offsetY; - this.canvas.style.cursor = "grabbing"; - } else if (!this.readonly) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - this.startX = pos.x; - this.startY = pos.y; - - if (this.currentTool === "select") { - // First check if clicking on a resize handle - const handle = this.getResizeHandleAtPoint(pos); - if (handle) { - this.resizeHandles.active = true; - this.resizeHandles.activeHandle = handle; - this.canvas.style.cursor = handle.cursor; - return; - } + const pos = this.getTransformedPosition(e.clientX, e.clientY); - // Then check if clicking inside selection area - if (this.selectedStrokes.length > 0 && this.selectionRect) { - const clickPoint = { x: pos.x, y: pos.y }; - if (this.isPointInRect(clickPoint, this.selectionRect)) { - // Start moving the selection - this.moveSelection.active = true; - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - this.canvas.style.cursor = "move"; - return; - } - } + // Show the resize cursor when hovering a selection handle + if ( + this.currentTool === "select" && + !this.isSelecting && + !this.moveSelection.active && + !this.resizeHandles.active + ) { + const handle = this.getResizeHandleAtPoint(pos); + this.canvas.style.cursor = handle ? handle.cursor : "default"; + } - // Check if clicking on any stroke - const clickedPoint = { x: pos.x, y: pos.y }; - let clickedIndex = -1; - - // Check selected strokes first - for (const index of this.selectedStrokes) { - if ( - this.isPointInStroke(clickedPoint, this.drawingHistory[index]) - ) { - clickedIndex = index; - break; - } - } + this.pointerMove(pos); + } - // If not clicking on selected stroke, check others - if (clickedIndex === -1) { - for (let i = this.drawingHistory.length - 1; i >= 0; i--) { - if (this.isPointInStroke(clickedPoint, this.drawingHistory[i])) { - clickedIndex = i; - break; - } - } - } + onMouseUp() { + if (this.isPanning) { + this.isPanning = false; + this.canvas.style.cursor = "default"; + this.startX = null; + this.startY = null; + } else if (!this.readonly) { + this.pointerUp(); + } + } - if (clickedIndex >= 0) { - // Clicked on a stroke - if ( - !e.ctrlKey && - !e.metaKey && - !this.selectedStrokes.includes(clickedIndex) - ) { - // New selection if not holding Ctrl/Cmd - this.selectedStrokes = [clickedIndex]; - } else if ( - (e.ctrlKey || e.metaKey) && - this.selectedStrokes.includes(clickedIndex) - ) { - // Deselect if holding Ctrl/Cmd and clicking on selected stroke - this.selectedStrokes = this.selectedStrokes.filter( - (i) => i !== clickedIndex, - ); - } else if (e.ctrlKey || e.metaKey) { - // Add to selection if holding Ctrl/Cmd - this.selectedStrokes.push(clickedIndex); - } + onMouseLeave() { + if (this.isDrawing) { + this.finishStroke(); + this.redrawCanvas(); + } + if (this.isPanning) { + this.isPanning = false; + this.canvas.style.cursor = "default"; + } + } - // Start move operation if clicked on selected stroke - if (this.selectedStrokes.includes(clickedIndex)) { - this.moveSelection.active = true; - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - this.canvas.style.cursor = "move"; - } - } else if (!e.ctrlKey && !e.metaKey) { - // Start selection rectangle if not clicking on any stroke and not holding modifier keys - this.isSelecting = true; - this.selectionRect = { - x: pos.x, - y: pos.y, - width: 0, - height: 0, - }; - this.selectedStrokes = []; - } + // Touch: one finger uses the current tool; two fingers pan and pinch-zoom + // (also in readonly mode). + onTouchStart(e) { + if (this.readonly && e.touches.length !== 2) { + return; + } + e.preventDefault(); + + const [first, second] = [e.touches[0], e.touches[1]]; + if (e.touches.length === 2) { + this.startPan( + (first.clientX + second.clientX) / 2, + (first.clientY + second.clientY) / 2, + ); + return; + } - this.redrawCanvas(); - } else if (this.currentTool === "pen") { - this.isDrawing = true; - this.currentStroke = [ - { - x: pos.x, - y: pos.y, - color: this.currentColor, - thickness: this.currentThickness, - }, - ]; + this.pointerDown(this.getTransformedPosition(first.clientX, first.clientY)); + } + + onTouchMove(e) { + if (this.readonly && e.touches.length !== 2) { + return; + } + e.preventDefault(); + + const [first, second] = [e.touches[0], e.touches[1]]; + if (e.touches.length === 2) { + // Pinch-to-zoom: compare finger distance with the previous move + const distance = Math.hypot( + first.clientX - second.clientX, + first.clientY - second.clientY, + ); + if (e.target === this.canvas) { + if (typeof this.lastDistance === "number") { + const zoomFactor = distance > this.lastDistance ? 1.01 : 0.99; + this.scale = Math.min(Math.max(0.1, this.scale * zoomFactor), 10); } + this.lastDistance = distance; } - }); - this.canvas.addEventListener("mousemove", (e) => { - if (this.readonly && !this.isPanning) { - return; - } // Only handle panning in readonly mode - - // Update cursor based on resize handles when in select mode - if ( - this.currentTool === "select" && - !this.isSelecting && - !this.moveSelection.active && - !this.resizeHandles.active - ) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - const handle = this.getResizeHandleAtPoint(pos); - this.canvas.style.cursor = handle ? handle.cursor : "default"; - } + this.panTo( + (first.clientX + second.clientX) / 2, + (first.clientY + second.clientY) / 2, + ); + return; + } - if (this.isPanning) { - this.offsetX = e.clientX - this.startPanX; - this.offsetY = e.clientY - this.startPanY; - this.applyPanLimits(); - this.redrawCanvas(); - } else if (this.resizeHandles.active) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - const dx = pos.x - this.startX; - const dy = pos.y - this.startY; - - // Resize the shapes - this.resizeSelectedShapes(this.resizeHandles.activeHandle, dx, dy); - - this.startX = pos.x; - this.startY = pos.y; - this.redrawCanvas(); - } else if (this.isDrawing && this.currentTool === "pen") { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - - // Calculate velocity for pressure - const velocity = Math.sqrt( - Math.pow(pos.x - this.lastX, 2) + Math.pow(pos.y - this.lastY, 2), - ); + this.pointerMove(this.getTransformedPosition(first.clientX, first.clientY)); + } - // Smooth velocity - this.lastVelocity = this.lastVelocity - ? this.lastVelocity * this.velocityFilterWeight + - velocity * (1 - this.velocityFilterWeight) - : velocity; + onTouchEnd(e) { + if (e.touches.length > 0) { + return; + } + this.isPanning = false; + this.lastDistance = null; + // A gesture can begin with one finger and add a second; finish it either way + if (!this.readonly) { + this.pointerUp(); + } + } + + startPan(clientX, clientY) { + this.isPanning = true; + this.startPanX = clientX - this.offsetX; + this.startPanY = clientY - this.offsetY; + } - // Add point with pressure data - this.currentStroke.push({ + panTo(clientX, clientY) { + this.offsetX = clientX - this.startPanX; + this.offsetY = clientY - this.startPanY; + this.applyPanLimits(); + this.redrawCanvas(); + } + + // Tool handling shared by mouse and touch. `additive` is Ctrl/Cmd: add to or + // remove from the selection instead of replacing it. + pointerDown(pos, additive = false) { + this.gestureLayerValid = false; + this.startX = pos.x; + this.startY = pos.y; + + if (this.currentTool === "select") { + this.selectAt(pos, additive); + } else if (this.currentTool === "pen") { + this.isDrawing = true; + this.currentStroke = [ + { x: pos.x, y: pos.y, color: this.currentColor, - pressure: Math.max(0.1, 1 - this.lastVelocity / 4), thickness: this.currentThickness, - }); - - this.redrawCanvas(); - - this.lastX = pos.x; - this.lastY = pos.y; - } else if (this.isSelecting) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - this.selectionRect.width = pos.x - this.selectionRect.x; - this.selectionRect.height = pos.y - this.selectionRect.y; - this.redrawCanvas(); - } else if (this.moveSelection.active) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - const dx = pos.x - this.moveSelection.startX; - const dy = pos.y - this.moveSelection.startY; - this.moveSelectedStrokes(dx, dy); - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - this.redrawCanvas(); - } else if ( - this.startX !== null && - this.currentTool !== "pen" && - this.currentTool !== "select" - ) { - // Update preview shape - const pos = this.getTransformedPosition(e.clientX, e.clientY); - - this.previewShape = { - type: this.currentTool, - x: this.startX, - y: this.startY, - width: pos.x - this.startX, - height: pos.y - this.startY, - color: this.currentColor, - thickness: this.currentThickness, - isFilled: this.isFilled, - }; - - this.redrawCanvas(); - } - }); - - this.canvas.addEventListener("mouseup", (e) => { - if (this.readonly && !this.isPanning) { - return; - } // Only handle panning in readonly mode - - if (this.resizeHandles.active) { - this.resizeHandles.active = false; - this.resizeHandles.activeHandle = null; - this.canvas.style.cursor = "default"; - // Record resize operation in history - // You might want to add a specific resize operation type - } else if (this.isPanning) { - this.isPanning = false; - this.canvas.style.cursor = "default"; - } else if (this.currentTool === "select") { - if (this.isSelecting && !this.moveSelection.active) { - this.selectStrokesInRect(this.selectionRect); - } + }, + ]; + } + } - // Record the move operation in history only when the move is complete - if ( - this.moveSelection.active && - this.moveSelection.totalDx !== undefined - ) { - const moveOp = { - type: "move", - strokes: [...this.selectedStrokes], - dx: this.moveSelection.totalDx, - dy: this.moveSelection.totalDy, - }; - this.drawingHistory.push(moveOp); - this.redoHistory = []; - } + pointerMove(pos) { + if (this.resizeHandles.active) { + this.resizeSelectedShapes( + this.resizeHandles.activeHandle, + pos.x - this.startX, + pos.y - this.startY, + ); + this.startX = pos.x; + this.startY = pos.y; + this.redrawGesture(); + } else if (this.isDrawing && this.currentTool === "pen") { + this.currentStroke.push({ + x: pos.x, + y: pos.y, + color: this.currentColor, + thickness: this.currentThickness, + }); + this.redrawGesture(); + } else if (this.isSelecting) { + this.selectionRect.width = pos.x - this.selectionRect.x; + this.selectionRect.height = pos.y - this.selectionRect.y; + this.redrawGesture(); + } else if (this.moveSelection.active) { + this.moveSelectedStrokes( + pos.x - this.moveSelection.startX, + pos.y - this.moveSelection.startY, + ); + this.moveSelection.startX = pos.x; + this.moveSelection.startY = pos.y; + this.redrawGesture(); + } else if ( + this.startX !== null && + this.currentTool !== "pen" && + this.currentTool !== "select" + ) { + this.previewShape = { + type: this.currentTool, + x: this.startX, + y: this.startY, + width: pos.x - this.startX, + height: pos.y - this.startY, + color: this.currentColor, + thickness: this.currentThickness, + isFilled: this.isFilled, + }; + this.redrawGesture(); + } + } - this.isSelecting = false; - this.moveSelection.active = false; - this.moveSelection.totalDx = undefined; - this.moveSelection.totalDy = undefined; - this.canvas.style.cursor = "default"; - - // Keep the selection rectangle for the selected strokes - if (this.selectedStrokes.length > 0) { - // Update selection rectangle to encompass all selected strokes - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - this.selectedStrokes.forEach((index) => { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke) && !stroke[0]?.type) { - // For freehand strokes - stroke.forEach((point) => { - minX = Math.min(minX, point.x); - minY = Math.min(minY, point.y); - maxX = Math.max(maxX, point.x); - maxY = Math.max(maxY, point.y); - }); - } else if (stroke[0]?.type) { - // For shapes - const bounds = this.getShapeBounds(stroke[0]); - minX = Math.min(minX, bounds.x); - minY = Math.min(minY, bounds.y); - maxX = Math.max(maxX, bounds.x + bounds.width); - maxY = Math.max(maxY, bounds.y + bounds.height); - } - }); + pointerUp() { + if (this.resizeHandles.active) { + this.resizeHandles.active = false; + this.resizeHandles.activeHandle = null; + this.canvas.style.cursor = "default"; + } else if (this.currentTool === "select") { + this.finishSelection(); + } else if (this.currentTool === "pen" && this.isDrawing) { + this.finishStroke(); + } else if (this.startX !== null && this.previewShape) { + this.drawingHistory.push([this.previewShape]); + this.redoHistory = []; + this.previewShape = null; + this.redrawCanvas(); + } - this.selectionRect = { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; - } else { - this.selectionRect = null; - } + this.startX = null; + this.startY = null; + } - this.redrawCanvas(); - } else if (this.currentTool === "pen" && this.isDrawing) { - this.isDrawing = false; - if (this.currentStroke.length > 1) { - this.drawingHistory.push(this.currentStroke); - this.redoHistory = []; // Clear redo history on new stroke - } - this.currentStroke = []; - } else if (this.startX !== null && this.previewShape) { - // Add the final shape to history - this.drawingHistory.push([this.previewShape]); - this.redoHistory = []; // Clear redo history on new shape - this.previewShape = null; // Clear preview shape - this.redrawCanvas(); - } + // Select tool press: grab a resize handle, start moving the selection, pick + // the stroke under the pointer, or start a selection box. + selectAt(pos, additive) { + const handle = this.getResizeHandleAtPoint(pos); + if (handle) { + this.resizeHandles.active = true; + this.resizeHandles.activeHandle = handle; + this.canvas.style.cursor = handle.cursor; + return; + } - this.startX = null; - this.startY = null; - }); + if ( + this.selectedStrokes.length > 0 && + this.selectionRect && + this.isPointInRect(pos, this.selectionRect) + ) { + this.startMove(pos); + return; + } - this.canvas.addEventListener("mouseleave", () => { - if (this.isDrawing) { - this.isDrawing = false; - if (this.currentStroke.length > 1) { - this.drawingHistory.push(this.currentStroke); + // Prefer already-selected strokes, then the topmost stroke + let clickedIndex = this.selectedStrokes.find((index) => + this.isPointInStroke(pos, this.drawingHistory[index]), + ); + if (clickedIndex === undefined) { + clickedIndex = -1; + for (let i = this.drawingHistory.length - 1; i >= 0; i--) { + if (this.isPointInStroke(pos, this.drawingHistory[i])) { + clickedIndex = i; + break; } - this.currentStroke = []; - this.redrawCanvas(); - } - if (this.isPanning) { - this.isPanning = false; - this.canvas.style.cursor = "default"; } - }); - - // Touch events for mobile - this.canvas.addEventListener("touchstart", (e) => { - if (this.readonly && e.touches.length !== 2) { - return; - } // Only allow two-finger pan/zoom in readonly mode - - e.preventDefault(); + } - if (e.touches.length === 2) { - // Two fingers for panning - this.isPanning = true; - this.startPanX = - (e.touches[0].clientX + e.touches[1].clientX) / 2 - this.offsetX; - this.startPanY = - (e.touches[0].clientY + e.touches[1].clientY) / 2 - this.offsetY; - return; + if (clickedIndex >= 0) { + const wasSelected = this.selectedStrokes.includes(clickedIndex); + if (!additive && !wasSelected) { + this.selectedStrokes = [clickedIndex]; + } else if (additive && wasSelected) { + this.selectedStrokes = this.selectedStrokes.filter( + (i) => i !== clickedIndex, + ); + } else if (additive) { + this.selectedStrokes.push(clickedIndex); } - const touch = e.touches[0]; - const pos = this.getTransformedPosition(touch.clientX, touch.clientY); - - if (this.currentTool === "select") { - // First check if clicking on a resize handle - const handle = this.getResizeHandleAtPoint(pos); - if (handle) { - this.resizeHandles.active = true; - this.resizeHandles.activeHandle = handle; - return; - } - - // Then check if clicking inside selection area - if (this.selectedStrokes.length > 0 && this.selectionRect) { - const clickPoint = { x: pos.x, y: pos.y }; - if (this.isPointInRect(clickPoint, this.selectionRect)) { - // Start moving the selection - this.moveSelection.active = true; - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - return; - } - } - - // Check if clicking on any stroke - const clickedPoint = { x: pos.x, y: pos.y }; - let clickedIndex = -1; - - // Check selected strokes first - for (const index of this.selectedStrokes) { - if (this.isPointInStroke(clickedPoint, this.drawingHistory[index])) { - clickedIndex = index; - break; - } - } - - // If not clicking on selected stroke, check others - if (clickedIndex === -1) { - for (let i = this.drawingHistory.length - 1; i >= 0; i--) { - if (this.isPointInStroke(clickedPoint, this.drawingHistory[i])) { - clickedIndex = i; - break; - } - } - } - - if (clickedIndex >= 0) { - // Clicked on a stroke - this.selectedStrokes = [clickedIndex]; - this.moveSelection.active = true; - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - } else { - // Start selection rectangle - this.isSelecting = true; - this.selectionRect = { - x: pos.x, - y: pos.y, - width: 0, - height: 0, - }; - this.selectedStrokes = []; - } - - this.redrawCanvas(); - } else if (this.currentTool === "pen") { - this.isDrawing = true; - this.lastX = pos.x; - this.lastY = pos.y; - - this.currentStroke = [ - { - x: pos.x, - y: pos.y, - color: this.currentColor, - thickness: this.currentThickness, - }, - ]; - } else { - // For shapes, store the starting position - this.startX = pos.x; - this.startY = pos.y; + if (this.selectedStrokes.includes(clickedIndex)) { + this.startMove(pos); } - }); - - this.canvas.addEventListener("touchmove", (e) => { - if (this.readonly && e.touches.length !== 2) { - return; - } // Only allow two-finger pan/zoom in readonly mode - - e.preventDefault(); + } else if (!additive) { + this.isSelecting = true; + this.selectionRect = { x: pos.x, y: pos.y, width: 0, height: 0 }; + this.selectedStrokes = []; + } - if (e.touches.length === 2) { - // Two fingers for panning/zooming - const currentX = (e.touches[0].clientX + e.touches[1].clientX) / 2; - const currentY = (e.touches[0].clientY + e.touches[1].clientY) / 2; + this.redrawCanvas(); + } - // Calculate distance between fingers for pinch-to-zoom - const initialDistance = Math.hypot( - e.touches[0].clientX - e.touches[1].clientX, - e.touches[0].clientY - e.touches[1].clientY, - ); + startMove(pos) { + this.moveSelection.active = true; + this.moveSelection.startX = pos.x; + this.moveSelection.startY = pos.y; + this.canvas.style.cursor = "move"; + } - if (e.touches.length === 2 && e.target === this.canvas) { - if (typeof this.lastDistance === "number") { - const delta = initialDistance - this.lastDistance; - const zoomFactor = delta > 0 ? 1.01 : 0.99; - this.scale *= zoomFactor; - this.scale = Math.min(Math.max(0.1, this.scale), 10); - } - this.lastDistance = initialDistance; - } + // Select tool release: apply the selection box, record a finished move, and + // fit the selection rectangle to the selected strokes. + finishSelection() { + if (this.isSelecting && !this.moveSelection.active) { + this.selectStrokesInRect(this.selectionRect); + } - this.offsetX = currentX - this.startPanX; - this.offsetY = currentY - this.startPanY; + // Record the move in history only once it is complete + if (this.moveSelection.active && this.moveSelection.totalDx !== undefined) { + this.drawingHistory.push({ + type: "move", + strokes: [...this.selectedStrokes], + dx: this.moveSelection.totalDx, + dy: this.moveSelection.totalDy, + }); + this.redoHistory = []; + } - // Apply pan limits - this.applyPanLimits(); + this.isSelecting = false; + this.moveSelection.active = false; + this.moveSelection.totalDx = undefined; + this.moveSelection.totalDy = undefined; + this.canvas.style.cursor = "default"; + this.selectionRect = this.selectedStrokesBounds(); + this.redrawCanvas(); + } - this.redrawCanvas(); - return; - } + // Bounding box of the selected strokes and shapes, or null if none + selectedStrokesBounds() { + if (this.selectedStrokes.length === 0) { + return null; + } - const touch = e.touches[0]; - const pos = this.getTransformedPosition(touch.clientX, touch.clientY); - - if (this.currentTool === "select") { - if (this.resizeHandles.active && this.resizeHandles.activeHandle) { - // Handle resize operation - const dx = pos.x - this.startX; - const dy = pos.y - this.startY; - this.resizeSelectedShapes(this.resizeHandles.activeHandle, dx, dy); - this.startX = pos.x; - this.startY = pos.y; - } else if (this.moveSelection.active) { - // Handle move operation - const dx = pos.x - this.moveSelection.startX; - const dy = pos.y - this.moveSelection.startY; - this.moveSelectedStrokes(dx, dy); - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - } else if (this.isSelecting) { - // Update selection rectangle - this.selectionRect.width = pos.x - this.selectionRect.x; - this.selectionRect.height = pos.y - this.selectionRect.y; - } - this.redrawCanvas(); - } else if (this.isDrawing && this.currentTool === "pen") { - // Add to current stroke for pen tool - this.currentStroke.push({ - x: pos.x, - y: pos.y, - color: this.currentColor, - thickness: this.currentThickness, + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + this.selectedStrokes.forEach((index) => { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke) && !stroke[0]?.type) { + // Freehand stroke + stroke.forEach((point) => { + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); }); - - this.redrawCanvas(); - - this.lastX = pos.x; - this.lastY = pos.y; - } else if ( - this.startX !== null && - this.currentTool !== "pen" && - this.currentTool !== "select" - ) { - // Update preview shape for shape tools - this.previewShape = { - type: this.currentTool, - x: this.startX, - y: this.startY, - width: pos.x - this.startX, - height: pos.y - this.startY, - color: this.currentColor, - thickness: this.currentThickness, - isFilled: this.isFilled, - }; - - this.redrawCanvas(); + } else if (stroke[0]?.type) { + // Shape + const bounds = this.getShapeBounds(stroke[0]); + minX = Math.min(minX, bounds.x); + minY = Math.min(minY, bounds.y); + maxX = Math.max(maxX, bounds.x + bounds.width); + maxY = Math.max(maxY, bounds.y + bounds.height); } }); - this.canvas.addEventListener("touchend", (e) => { - if (e.touches.length === 0) { - this.isPanning = false; - this.lastDistance = null; - - if (this.currentTool === "select") { - if (this.isSelecting) { - this.selectStrokesInRect(this.selectionRect); - this.isSelecting = false; - } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } - // Record the move operation in history only when the move is complete - if ( - this.moveSelection.active && - this.moveSelection.totalDx !== undefined - ) { - const moveOp = { - type: "move", - strokes: [...this.selectedStrokes], - dx: this.moveSelection.totalDx, - dy: this.moveSelection.totalDy, - }; - this.drawingHistory.push(moveOp); - this.redoHistory = []; - } + // End the pen stroke; a single point is a tap, not a stroke + finishStroke() { + this.isDrawing = false; + if (this.currentStroke.length > 1) { + this.drawingHistory.push(this.currentStroke); + this.redoHistory = []; + } + this.currentStroke = []; + } - this.moveSelection.active = false; - this.moveSelection.totalDx = undefined; - this.moveSelection.totalDy = undefined; - this.resizeHandles.active = false; - this.resizeHandles.activeHandle = null; - - // Update selection rectangle for selected strokes - if (this.selectedStrokes.length > 0) { - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - this.selectedStrokes.forEach((index) => { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke) && !stroke[0]?.type) { - stroke.forEach((point) => { - minX = Math.min(minX, point.x); - minY = Math.min(minY, point.y); - maxX = Math.max(maxX, point.x); - maxY = Math.max(maxY, point.y); - }); - } else if (stroke[0]?.type) { - const bounds = this.getShapeBounds(stroke[0]); - minX = Math.min(minX, bounds.x); - minY = Math.min(minY, bounds.y); - maxX = Math.max(maxX, bounds.x + bounds.width); - maxY = Math.max(maxY, bounds.y + bounds.height); - } - }); - - this.selectionRect = { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; - } - } else if (this.isDrawing && this.currentTool === "pen") { - this.isDrawing = false; - if (this.currentStroke.length > 1) { - this.drawingHistory.push(this.currentStroke); - } - this.currentStroke = []; - } else if (this.startX !== null && this.previewShape) { - // Add the final shape to history - this.drawingHistory.push([this.previewShape]); - this.previewShape = null; - } + onKeyDown(e) { + if (this.readonly) { + return; + } - this.startX = null; - this.startY = null; - this.redrawCanvas(); - } - }); + if (e.key === "Escape" && (this.previewShape || this.selectionRect)) { + this.previewShape = null; + this.selectionRect = null; + this.isSelecting = false; + this.startX = null; + this.startY = null; + this.selectedStrokes = []; + this.redrawCanvas(); + this.showStatus("Operation cancelled"); + return; + } - // Add keyboard shortcuts - window.addEventListener("keydown", (e) => { - if (this.readonly) { - return; - } // Disable keyboard shortcuts in readonly mode - - if (e.key === "Escape" && (this.previewShape || this.selectionRect)) { - this.previewShape = null; - this.selectionRect = null; - this.startX = null; - this.startY = null; - this.selectedStrokes = []; - this.redrawCanvas(); - this.showStatus("Operation cancelled"); + if (e.ctrlKey || e.metaKey) { + // Ctrl on Windows/Linux, Cmd on Mac + const key = e.key.toLowerCase(); + const tool = { + a: "select", + p: "pen", + r: "rectangle", + l: "line", + h: "hexagon", + }[key]; + if (tool) { + e.preventDefault(); + this.setTool(tool); return; } - if (e.ctrlKey || e.metaKey) { - // Support both Windows/Linux and Mac - switch (e.key.toLowerCase()) { - case "z": - e.preventDefault(); - if (e.shiftKey) { - this.redo(); - } else { - this.undo(); - } - break; - case "y": - e.preventDefault(); + switch (key) { + case "z": + e.preventDefault(); + if (e.shiftKey) { this.redo(); - break; - case "a": - e.preventDefault(); - this.setTool("select"); - break; - case "p": - e.preventDefault(); - this.setTool("pen"); - break; - case "r": - e.preventDefault(); - this.setTool("rectangle"); - break; - case "c": - if (!e.shiftKey) { - e.preventDefault(); - if (e.altKey) { - this.setTool("circle"); - } else { - this.copySelectedStrokes(); - } - } - break; - case "x": - e.preventDefault(); - this.cutSelectedStrokes(); - break; - case "v": - e.preventDefault(); - this.pasteStrokes(); - break; - case "l": - e.preventDefault(); - this.setTool("line"); - break; - case "h": + } else { + this.undo(); + } + break; + case "y": + e.preventDefault(); + this.redo(); + break; + case "c": + if (!e.shiftKey) { e.preventDefault(); - this.setTool("hexagon"); - break; - case "s": - if (!e.shiftKey) { - e.preventDefault(); - this.setTool("star"); + if (e.altKey) { + this.setTool("circle"); + } else { + this.copySelectedStrokes(); } - break; - case "f": + } + break; + case "x": + e.preventDefault(); + this.cutSelectedStrokes(); + break; + case "v": + e.preventDefault(); + this.pasteStrokes(); + break; + case "s": + if (!e.shiftKey) { e.preventDefault(); - this.setFill(!this.isFilled); - break; - } - } else if ( - (e.key === "Backspace" || e.key === "Delete") && - this.selectedStrokes.length > 0 - ) { - e.preventDefault(); - this.deleteSelectedStrokes(); + this.setTool("star"); + } + break; + case "f": + e.preventDefault(); + this.setFill(!this.isFilled); + break; } - }); + } else if ( + (e.key === "Backspace" || e.key === "Delete") && + this.selectedStrokes.length > 0 + ) { + e.preventDefault(); + this.deleteSelectedStrokes(); + } } // Perfect freehand drawing helper methods @@ -1969,10 +1787,7 @@ class Canvas { height: Math.abs(rect.height), }; - // Don't clear previous selection if Ctrl/Cmd is held - if (!this.lastEvent?.ctrlKey && !this.lastEvent?.metaKey) { - this.selectedStrokes = []; - } + this.selectedStrokes = []; // Keep track of newly selected strokes const newlySelected = []; diff --git a/app/static/js/scout/form-canvas.js b/app/static/js/scout/form-canvas.js index 97d0f37..801f037 100644 --- a/app/static/js/scout/form-canvas.js +++ b/app/static/js/scout/form-canvas.js @@ -124,19 +124,10 @@ function initScoutFormCanvas({ updateUIControls, onCanvasReady = () => {}, resiz { passive: false }, ); - // Prevent page scrolling when middle mouse button is pressed + // Middle-button drag pans the canvas; stop the browser's autoscroll canvas.addEventListener("mousedown", (e) => { if (e.button === 1 && e.target === canvas) { - // Middle mouse button e.preventDefault(); - CanvasField.startPanning(e); - } - }); - - // Add mouseup handler for panning - canvas.addEventListener("mouseup", (e) => { - if (e.button === 1 && e.target === canvas) { - CanvasField.stopPanning(); } }); diff --git a/app/static/js/service-worker.js b/app/static/js/service-worker.js index 35f4749..d9743b5 100644 --- a/app/static/js/service-worker.js +++ b/app/static/js/service-worker.js @@ -1,9 +1,9 @@ -const CACHE_NAME = 'castle-app-v11'; -const STATIC_CACHE_NAME = 'castle-app-static-v11'; -const DYNAMIC_CACHE_NAME = 'castle-app-dynamic-v11'; -const PAGE_CACHE_NAME = 'castle-app-pages-v11'; +const CACHE_NAME = 'castle-app-v13'; +const STATIC_CACHE_NAME = 'castle-app-static-v13'; +const DYNAMIC_CACHE_NAME = 'castle-app-dynamic-v13'; +const PAGE_CACHE_NAME = 'castle-app-pages-v13'; -const CACHE_VERSION = '12'; // Increment this when making changes +const CACHE_VERSION = '14'; // Increment this when making changes // Add timestamped version query parameter to bust cache const CACHE_TIMESTAMP = new Date().getTime(); diff --git a/package.json b/package.json index c9c8b0e..1c89b18 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "castle-styles", "private": true, "scripts": { - "test": "node --test tests/test_browser_resilience.js", + "test": "node --test tests/test_browser_resilience.js tests/test_canvas.js", "build:css": "tailwindcss -i app/static/css/tailwind.input.css -o app/static/css/tailwind.css --minify" }, "devDependencies": { diff --git a/tests/test_canvas.js b/tests/test_canvas.js new file mode 100644 index 0000000..485b3bb --- /dev/null +++ b/tests/test_canvas.js @@ -0,0 +1,437 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const test = require('node:test'); +const vm = require('node:vm'); + +const SOURCE = fs.readFileSync('app/static/js/Canvas.js', 'utf8'); +const BOUNDS = {left: 0, top: 0, width: 800, height: 400}; + +// Canvas.js in a fake browser: an 800x400 canvas whose 2D context accepts any call. +function whiteboard(options = {}) { + const listeners = new Map(); + const on = target => (name, handler) => listeners.set(`${target}:${name}`, handler); + const ctx = new Proxy({}, {get: (props, key) => key in props ? props[key] : () => {}}); + const canvas = {width: 0, height: 0, style: {}, getContext: () => ctx, + addEventListener: on('canvas'), getBoundingClientRect: () => BOUNDS}; + const container = {addEventListener: on('container'), getBoundingClientRect: () => BOUNDS}; + const statuses = []; + const Canvas = vm.runInNewContext(`${SOURCE}\nCanvas;`, { + window: {devicePixelRatio: 1, addEventListener: on('window')}, + document: {getElementById: () => null, createElement: () => ({getContext: () => ctx})}, + matchMedia: () => ({addEventListener() {}}), + localforage: {createInstance: () => ({setItem: async () => {}})}, + Image: class {}, setInterval: () => 0, clearInterval() {}, setTimeout() {}, + console: {log() {}, warn() {}, error() {}}, + }); + const board = new Canvas({canvas, container, showStatus: message => statuses.push(message), ...options}); + // Identity view so client coordinates equal drawing coordinates. + Object.assign(board, {scale: 1, offsetX: 0, offsetY: 0}); + const fire = (key, event) => listeners.get(key)({preventDefault() {}, ...event}); + return {board, canvas, statuses, fire}; +} + +const mouseEvent = (x, y, mods = {}) => ({clientX: x, clientY: y, button: 0, ...mods}); +const touchEvent = (page, points) => ({target: page.canvas, + touches: points.map(([x, y]) => ({clientX: x, clientY: y}))}); + +// Single-pointer input through each device's events; touch has no modifier keys. +const devices = { + mouse: page => ({ + down: (x, y, mods) => page.fire('canvas:mousedown', mouseEvent(x, y, mods)), + move: (x, y) => page.fire('canvas:mousemove', mouseEvent(x, y)), + up: (x, y) => page.fire('canvas:mouseup', mouseEvent(x, y)), + }), + touch: page => ({ + down: (x, y) => page.fire('canvas:touchstart', touchEvent(page, [[x, y]])), + move: (x, y) => page.fire('canvas:touchmove', touchEvent(page, [[x, y]])), + up: () => page.fire('canvas:touchend', touchEvent(page, [])), + }), +}; + +function drag(input, ...points) { + input.down(...points[0]); + points.slice(1).forEach(point => input.move(...point)); + input.up(...points.at(-1)); +} + +function click(input, x, y, mods) { + input.down(x, y, mods); + input.up(x, y); +} + +// Values from the sandbox realm compare by prototype; copy them into this realm. +const plain = value => JSON.parse(JSON.stringify(value)); +const xy = stroke => Array.from(stroke, ({x, y}) => [x, y]); +const key = (page, k, mods = {}) => page.fire('window:keydown', {key: k, ...mods}); + +for (const [device, connect] of Object.entries(devices)) { + test(`${device}: pen drag records one stroke with colour and thickness`, () => { + const page = whiteboard({initialColor: '#ff0000', initialThickness: 5}); + drag(connect(page), [10, 10], [20, 10], [30, 15]); + assert.equal(page.board.drawingHistory.length, 1); + const stroke = page.board.drawingHistory[0]; + assert.deepEqual(xy(stroke), [[10, 10], [20, 10], [30, 15]]); + assert.ok(stroke.every(p => p.color === '#ff0000' && p.thickness === 5)); + assert.equal(page.board.currentStroke.length, 0); + assert.equal(page.board.isDrawing, false); + }); + + test(`${device}: a pen tap without movement records nothing`, () => { + const page = whiteboard(); + drag(connect(page), [10, 10]); + assert.equal(page.board.drawingHistory.length, 0); + }); + + test(`${device}: shape drag records the shape and clears the preview`, () => { + const page = whiteboard(); + page.board.setTool('rectangle'); + page.board.setFill(true); + drag(connect(page), [10, 10], [30, 20], [50, 40]); + assert.equal(page.board.drawingHistory.length, 1); + const [shape] = page.board.drawingHistory[0]; + assert.deepEqual(plain(shape), {type: 'rectangle', x: 10, y: 10, width: 40, height: 30, + color: '#000000', thickness: 3, isFilled: true}); + assert.equal(page.board.previewShape, null); + assert.equal(page.board.startX, null); + }); + + test(`${device}: a shape tap without movement records nothing`, () => { + const page = whiteboard(); + page.board.setTool('circle'); + drag(connect(page), [10, 10]); + assert.equal(page.board.drawingHistory.length, 0); + }); + + test(`${device}: drawing after undo discards the redo history`, () => { + const page = whiteboard(); + const input = connect(page); + drag(input, [10, 10], [20, 20]); + page.board.undo(); + assert.equal(page.board.redoHistory.length, 1); + drag(input, [50, 50], [60, 60]); + assert.equal(page.board.redoHistory.length, 0); + page.board.setTool('line'); + drag(input, [0, 0], [5, 5]); + page.board.undo(); + drag(input, [0, 0], [9, 9]); + assert.equal(page.board.redoHistory.length, 0); + }); + + test(`${device}: dragging a stroke moves it and undo/redo replay the move`, () => { + const page = whiteboard(); + const input = connect(page); + drag(input, [10, 10], [50, 10]); + page.board.setTool('select'); + drag(input, [30, 10], [35, 15], [40, 20]); + assert.deepEqual(plain(page.board.selectedStrokes), [0]); + assert.deepEqual(xy(page.board.drawingHistory[0]), [[20, 20], [60, 20]]); + assert.deepEqual(plain(page.board.drawingHistory[1]), {type: 'move', strokes: [0], dx: 10, dy: 10}); + assert.deepEqual(plain(page.board.selectionRect), {x: 20, y: 20, width: 40, height: 0}); + assert.equal(page.board.moveSelection.active, false); + page.board.undo(); + assert.deepEqual(xy(page.board.drawingHistory[0]), [[10, 10], [50, 10]]); + page.board.redo(); + assert.deepEqual(xy(page.board.drawingHistory[0]), [[20, 20], [60, 20]]); + }); + + test(`${device}: dragging inside the selection moves every selected stroke`, () => { + const page = whiteboard(); + const input = connect(page); + drag(input, [10, 10], [20, 10]); + drag(input, [10, 30], [20, 30]); + page.board.setTool('select'); + drag(input, [0, 0], [100, 100]); + assert.deepEqual(plain(page.board.selectedStrokes), [0, 1]); + drag(input, [15, 20], [25, 20]); + assert.deepEqual(plain(page.board.selectedStrokes), [0, 1]); + assert.deepEqual(xy(page.board.drawingHistory[0]), [[20, 10], [30, 10]]); + assert.deepEqual(xy(page.board.drawingHistory[1]), [[20, 30], [30, 30]]); + }); + + test(`${device}: box select picks touching strokes and shapes, empty box clears`, () => { + const page = whiteboard(); + const input = connect(page); + drag(input, [10, 10], [20, 10]); + page.board.setTool('rectangle'); + drag(input, [100, 100], [140, 130]); + drag(input, [300, 300], [310, 310]); + page.board.setTool('select'); + drag(input, [0, 0], [120, 120]); + assert.deepEqual(plain(page.board.selectedStrokes), [0, 1]); + assert.deepEqual(plain(page.board.selectionRect), {x: 10, y: 10, width: 130, height: 120}); + assert.equal(page.board.isSelecting, false); + drag(input, [500, 20], [600, 60]); + assert.deepEqual(plain(page.board.selectedStrokes), []); + assert.equal(page.board.selectionRect, null); + }); + + test(`${device}: dragging a corner handle resizes the selected shape`, () => { + const page = whiteboard(); + const input = connect(page); + page.board.setTool('rectangle'); + drag(input, [10, 10], [50, 40]); + page.board.setTool('select'); + drag(input, [0, 0], [60, 60]); + // Bottom-right handle covers (42..50, 32..40) + drag(input, [47, 37], [52, 42], [57, 47]); + const [shape] = page.board.drawingHistory[0]; + assert.deepEqual([shape.x, shape.y, shape.width, shape.height], [10, 10, 50, 40]); + assert.deepEqual(plain(page.board.selectionRect), {x: 10, y: 10, width: 50, height: 40}); + assert.equal(page.board.resizeHandles.active, false); + }); + + test(`${device}: read-only boards ignore single-pointer drawing and selection`, () => { + const page = whiteboard(); + const input = connect(page); + page.board.setReadonly(true); + drag(input, [10, 10], [50, 50]); + page.board.currentTool = 'select'; + drag(input, [0, 0], [60, 60]); + assert.equal(page.board.drawingHistory.length, 0); + assert.equal(page.board.selectionRect, null); + assert.equal(page.board.isPanning, false); + }); +} + +test('mouse: Ctrl/Cmd click adds and removes strokes from the selection', () => { + const page = whiteboard(); + const input = devices.mouse(page); + drag(input, [10, 10], [20, 10]); + drag(input, [10, 30], [20, 30]); + page.board.setTool('select'); + drag(input, [15, 10]); + click(input, 15, 30, {ctrlKey: true}); + assert.deepEqual(plain(page.board.selectedStrokes), [0, 1]); + page.board.selectionRect = null; + click(input, 15, 10, {metaKey: true}); + assert.deepEqual(plain(page.board.selectedStrokes), [1]); + page.board.selectionRect = null; + click(input, 300, 300, {ctrlKey: true}); + assert.deepEqual(plain(page.board.selectedStrokes), [1]); +}); + +test('mouse: clicking a stroke selects only it', () => { + const page = whiteboard(); + const input = devices.mouse(page); + drag(input, [10, 10], [20, 10]); + drag(input, [10, 30], [20, 30]); + page.board.setTool('select'); + drag(input, [15, 10]); + page.board.selectionRect = null; + drag(input, [15, 30]); + assert.deepEqual(plain(page.board.selectedStrokes), [1]); +}); + +test('mouse: cursor follows panning, moving, and resize handles', () => { + const page = whiteboard(); + const input = devices.mouse(page); + page.board.setTool('rectangle'); + drag(input, [10, 10], [50, 40]); + page.board.setTool('select'); + drag(input, [0, 0], [60, 60]); + input.move(47, 37); + assert.equal(page.canvas.style.cursor, 'se-resize'); + input.move(30, 20); + assert.equal(page.canvas.style.cursor, 'default'); + input.down(30, 20); + assert.equal(page.canvas.style.cursor, 'move'); + input.up(30, 20); + assert.equal(page.canvas.style.cursor, 'default'); + input.down(30, 20, {shiftKey: true}); + assert.equal(page.canvas.style.cursor, 'grabbing'); + input.up(30, 20); + assert.equal(page.canvas.style.cursor, 'default'); +}); + +test('mouse: shift or middle-button drag pans, including on read-only boards', () => { + for (const [mods, readonly] of [[{shiftKey: true}, false], [{button: 1}, false], [{shiftKey: true}, true]]) { + const page = whiteboard(); + const input = devices.mouse(page); + page.board.setReadonly(readonly); + Object.assign(page.board, {offsetX: 400, offsetY: 200}); + input.down(100, 100, mods); + input.move(130, 90); + assert.deepEqual([page.board.offsetX, page.board.offsetY], [430, 190]); + input.up(130, 90); + assert.equal(page.board.isPanning, false); + assert.equal(page.board.drawingHistory.length, 0); + } +}); + +test('mouse: leaving the canvas mid-stroke keeps the stroke and stops panning', () => { + const page = whiteboard(); + const input = devices.mouse(page); + drag(input, [0, 0], [5, 5]); + page.board.undo(); + input.down(10, 10); + input.move(20, 20); + page.fire('canvas:mouseleave', {}); + assert.equal(page.board.drawingHistory.length, 1); + assert.equal(page.board.redoHistory.length, 0); + assert.equal(page.board.isDrawing, false); + input.down(10, 10, {shiftKey: true}); + page.fire('canvas:mouseleave', {}); + assert.equal(page.board.isPanning, false); +}); + +test('mouse: wheel zooms around the pointer within limits', () => { + const page = whiteboard(); + Object.assign(page.board, {offsetX: 400, offsetY: 200}); + page.fire('container:wheel', {clientX: 400, clientY: 200, deltaY: -1}); + assert.ok(Math.abs(page.board.scale - 1.1) < 1e-9); + assert.deepEqual([page.board.offsetX, page.board.offsetY], [400, 200]); + for (let i = 0; i < 100; i++) page.fire('container:wheel', {clientX: 400, clientY: 200, deltaY: 1}); + assert.equal(page.board.scale, 0.1); +}); + +test('touch: two fingers pan and pinch, including on read-only boards', () => { + for (const readonly of [false, true]) { + const page = whiteboard(); + page.board.setReadonly(readonly); + Object.assign(page.board, {offsetX: 400, offsetY: 200}); + page.fire('canvas:touchstart', touchEvent(page, [[100, 100], [200, 100]])); + page.fire('canvas:touchmove', touchEvent(page, [[110, 90], [210, 90]])); + assert.deepEqual([page.board.offsetX, page.board.offsetY], [410, 190]); + assert.equal(page.board.scale, 1); + page.fire('canvas:touchmove', touchEvent(page, [[90, 90], [230, 90]])); + assert.equal(page.board.scale, 1.01); + page.fire('canvas:touchend', touchEvent(page, [])); + assert.equal(page.board.isPanning, false); + assert.equal(page.board.lastDistance, null); + assert.equal(page.board.drawingHistory.length, 0); + } +}); + +test('keyboard shortcuts edit, select tools, and stay off in read-only mode', () => { + const page = whiteboard(); + const input = devices.mouse(page); + drag(input, [10, 10], [20, 10]); + key(page, 'z', {ctrlKey: true}); + assert.equal(page.board.drawingHistory.length, 0); + key(page, 'Z', {ctrlKey: true, shiftKey: true}); + assert.equal(page.board.drawingHistory.length, 1); + key(page, 'z', {metaKey: true}); + key(page, 'y', {ctrlKey: true}); + assert.equal(page.board.drawingHistory.length, 1); + + for (const [k, mods, tool] of [['a', {}, 'select'], ['p', {}, 'pen'], ['r', {}, 'rectangle'], + ['c', {altKey: true}, 'circle'], ['l', {}, 'line'], ['h', {}, 'hexagon'], ['s', {}, 'star']]) { + key(page, k, {ctrlKey: true, ...mods}); + assert.equal(page.board.currentTool, tool); + } + key(page, 'f', {ctrlKey: true}); + assert.equal(page.board.isFilled, true); + + page.board.setTool('select'); + drag(input, [0, 0], [50, 50]); + key(page, 'c', {ctrlKey: true}); + key(page, 'v', {ctrlKey: true}); + assert.deepEqual(xy(page.board.drawingHistory[1]), [[30, 30], [40, 30]]); + assert.equal(page.board.drawingHistory[2].type, 'paste'); + key(page, 'x', {ctrlKey: true}); + assert.equal(page.board.drawingHistory.at(-1).type, 'delete'); + assert.equal(page.statuses.at(-1), 'Selection cut'); + + drag(input, [0, 0], [50, 50]); + key(page, 'Escape'); + assert.deepEqual(plain(page.board.selectedStrokes), []); + assert.equal(page.board.selectionRect, null); + drag(input, [0, 0], [50, 50]); + key(page, 'Delete'); + assert.equal(page.board.drawingHistory.at(-1).type, 'delete'); + + const length = page.board.drawingHistory.length; + page.board.setReadonly(true); + key(page, 'z', {ctrlKey: true}); + assert.equal(page.board.drawingHistory.length, length); +}); + +// Count how often strokes already on the board are drawn +function countSceneDraws(page) { + const draws = {count: 0}; + const drawStroke = page.board.drawStroke.bind(page.board); + page.board.drawStroke = (stroke, ...rest) => { + if (stroke !== page.board.currentStroke) draws.count++; + drawStroke(stroke, ...rest); + }; + return draws; +} + +test('dragging draws the existing strokes once, not on every move', () => { + const page = whiteboard(); + const input = devices.mouse(page); + drag(input, [10, 10], [20, 10]); + drag(input, [10, 30], [20, 30]); + const scene = countSceneDraws(page); + input.down(50, 50); + for (let x = 51; x <= 70; x++) input.move(x, 50); + assert.equal(scene.count, 2); + input.up(70, 50); + input.down(80, 80); + input.move(90, 90); + assert.equal(scene.count, 5, 'a new drag redraws the scene with the previous stroke'); +}); + +test('a full redraw during a drag discards the cached scene', () => { + const page = whiteboard(); + const input = devices.mouse(page); + drag(input, [10, 10], [20, 10]); + const scene = countSceneDraws(page); + input.down(50, 50); + input.move(55, 50); + input.move(60, 50); + assert.equal(scene.count, 1); + key(page, 'z', {ctrlKey: true}); + const afterUndo = scene.count; + input.move(65, 50); + input.move(70, 50); + assert.equal(afterUndo, 1, 'nothing left on the board after undo'); + assert.equal(page.board.gestureLayerValid, true); + drag(input, [100, 100], [110, 110]); + page.board.flipHorizontal(); + assert.equal(page.board.gestureLayerValid, false); +}); + +test('Escape cancels a selection box mid-drag', () => { + const page = whiteboard(); + const input = devices.mouse(page); + drag(input, [10, 10], [20, 10]); + page.board.setTool('select'); + input.down(0, 0); + input.move(50, 50); + key(page, 'Escape'); + input.move(60, 60); + input.up(60, 60); + assert.equal(page.board.isSelecting, false); + assert.deepEqual(plain(page.board.selectedStrokes), []); + assert.equal(page.board.selectionRect, null); +}); + +test('saved drawings load back with their view and transforms', () => { + const page = whiteboard(); + drag(devices.mouse(page), [10, 10], [20, 10]); + page.board.flipHorizontal(); + page.board.rotateClockwise(); + Object.assign(page.board, {scale: 2, offsetX: 420, offsetY: 210}); + const saved = page.board.saveToJSON(); + const copy = whiteboard(); + assert.equal(copy.board.loadFromJSON(saved), true); + for (const field of ['scale', 'offsetX', 'offsetY', 'flipX', 'flipY', 'rotation']) { + assert.equal(copy.board[field], page.board[field]); + } + assert.deepEqual(xy(copy.board.drawingHistory[0]), [[10, 10], [20, 10]]); + assert.equal(copy.board.loadFromJSON('{"version": 2}'), false); +}); + +test('drawing positions account for flip and rotation', () => { + const page = whiteboard(); + Object.assign(page.board, {offsetX: 400, offsetY: 200}); + page.board.flipHorizontal(); + drag(devices.mouse(page), [410, 220], [420, 220]); + assert.deepEqual(xy(page.board.drawingHistory[0]), [[-10, 20], [-20, 20]]); + page.board.resetTransformations(); + page.board.rotateClockwise(); + drag(devices.mouse(page), [410, 220], [420, 220]); + const rounded = xy(page.board.drawingHistory[1]).map(p => p.map(v => Math.round(v) + 0)); + assert.deepEqual(rounded, [[20, -10], [20, -20]]); +});