| import rough from "roughjs/bundled/rough.esm"; | |
| import getStroke from "perfect-freehand"; | |
| const generator = rough.generator(); | |
| export const createElement = (id, x1, y1, x2, y2, type, pencilSize = 3) => { | |
| switch (type) { | |
| case "line": | |
| case "rectangle": | |
| const roughElement = | |
| type === "line" | |
| ? generator.line(x1, y1, x2, y2) | |
| : generator.rectangle(x1, y1, x2 - x1, y2 - y1); | |
| return { id, x1, y1, x2, y2, type, roughElement }; | |
| case "pencil": | |
| return { id, type, points: [{ x: x1, y: y1, isErased: false }], size: pencilSize }; | |
| case "text": | |
| return { id, type, x1, y1, x2, y2, text: "" }; | |
| case "capture": | |
| return { id, type, x1, y1, x2, y2 }; | |
| default: | |
| throw new Error(`Type not recognised: ${type}`); | |
| } | |
| }; | |
| export const isPointNearEraser = (x, y, eraserX, eraserY, eraserSize) => { | |
| const distance = Math.sqrt(Math.pow(x - eraserX, 2) + Math.pow(y - eraserY, 2)); | |
| return distance <= eraserSize; | |
| }; | |
| const nearPoint = (x, y, x1, y1, name) => { | |
| return Math.abs(x - x1) < 5 && Math.abs(y - y1) < 5 ? name : null; | |
| }; | |
| const onLine = (x1, y1, x2, y2, x, y, maxDistance = 1) => { | |
| const a = { x: x1, y: y1 }; | |
| const b = { x: x2, y: y2 }; | |
| const c = { x, y }; | |
| const offset = distance(a, b) - (distance(a, c) + distance(b, c)); | |
| return Math.abs(offset) < maxDistance ? "inside" : null; | |
| }; | |
| const positionWithinElement = (x, y, element) => { | |
| const { type, x1, x2, y1, y2 } = element; | |
| switch (type) { | |
| case "line": | |
| const on = onLine(x1, y1, x2, y2, x, y); | |
| const start = nearPoint(x, y, x1, y1, "start"); | |
| const end = nearPoint(x, y, x2, y2, "end"); | |
| return start || end || on; | |
| case "rectangle": | |
| const topLeft = nearPoint(x, y, x1, y1, "tl"); | |
| const topRight = nearPoint(x, y, x2, y1, "tr"); | |
| const bottomLeft = nearPoint(x, y, x1, y2, "bl"); | |
| const bottomRight = nearPoint(x, y, x2, y2, "br"); | |
| const inside = x >= x1 && x <= x2 && y >= y1 && y <= y2 ? "inside" : null; | |
| return topLeft || topRight || bottomLeft || bottomRight || inside; | |
| case "pencil": | |
| const betweenAnyPoint = element.points.some((point, index) => { | |
| const nextPoint = element.points[index + 1]; | |
| if (!nextPoint) return false; | |
| return onLine(point.x, point.y, nextPoint.x, nextPoint.y, x, y, 5) != null; | |
| }); | |
| return betweenAnyPoint ? "inside" : null; | |
| case "text": | |
| return x >= x1 && x <= x2 && y >= y1 && y <= y2 ? "inside" : null; | |
| default: | |
| throw new Error(`Type not recognised: ${type}`); | |
| } | |
| }; | |
| export const distance = (a, b) => Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)); | |
| export const getElementAtPosition = (x, y, elements) => { | |
| return elements | |
| .map(element => ({ ...element, position: positionWithinElement(x, y, element) })) | |
| .find(element => element.position !== null); | |
| }; | |
| export const adjustElementCoordinates = element => { | |
| const { type, x1, y1, x2, y2 } = element; | |
| if (type === "rectangle") { | |
| const minX = Math.min(x1, x2); | |
| const maxX = Math.max(x1, x2); | |
| const minY = Math.min(y1, y2); | |
| const maxY = Math.max(y1, y2); | |
| return { x1: minX, y1: minY, x2: maxX, y2: maxY }; | |
| } else { | |
| if (x1 < x2 || (x1 === x2 && y1 < y2)) { | |
| return { x1, y1, x2, y2 }; | |
| } else { | |
| return { x1: x2, y1: y2, x2: x1, y2: y1 }; | |
| } | |
| } | |
| }; | |
| export const cursorForPosition = position => { | |
| switch (position) { | |
| case "tl": | |
| case "br": | |
| case "start": | |
| case "end": | |
| return "nwse-resize"; | |
| case "tr": | |
| case "bl": | |
| return "nesw-resize"; | |
| default: | |
| return "move"; | |
| } | |
| }; | |
| export const resizedCoordinates = (clientX, clientY, position, coordinates) => { | |
| const { x1, y1, x2, y2 } = coordinates; | |
| switch (position) { | |
| case "tl": | |
| case "start": | |
| return { x1: clientX, y1: clientY, x2, y2 }; | |
| case "tr": | |
| return { x1, y1: clientY, x2: clientX, y2 }; | |
| case "bl": | |
| return { x1: clientX, y1, x2, y2: clientY }; | |
| case "br": | |
| case "end": | |
| return { x1, y1, x2: clientX, y2: clientY }; | |
| default: | |
| return null; | |
| } | |
| }; | |
| export const getSvgPathFromStroke = stroke => { | |
| if (!stroke.length) return ""; | |
| const d = stroke.reduce( | |
| (acc, [x0, y0], i, arr) => { | |
| const [x1, y1] = arr[(i + 1) % arr.length]; | |
| acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); | |
| return acc; | |
| }, | |
| ["M", ...stroke[0], "Q"] | |
| ); | |
| d.push("Z"); | |
| return d.join(" "); | |
| }; | |
| export const drawElement = (roughCanvas, context, element) => { | |
| switch (element.type) { | |
| case "line": | |
| case "rectangle": | |
| roughCanvas.draw(element.roughElement); | |
| break; | |
| case "pencil": | |
| const nonErasedSegments = []; | |
| let currentSegment = []; | |
| element.points.forEach((point, index) => { | |
| if (!point.isErased) { | |
| currentSegment.push([point.x, point.y]); | |
| } else if (currentSegment.length > 0) { | |
| nonErasedSegments.push([...currentSegment]); | |
| currentSegment = []; | |
| } | |
| }); | |
| if (currentSegment.length > 0) { | |
| nonErasedSegments.push(currentSegment); | |
| } | |
| nonErasedSegments.forEach(segment => { | |
| if (segment.length > 1) { | |
| const stroke = getSvgPathFromStroke(getStroke(segment, { | |
| size: element.size || 3, | |
| thinning: 0.5, | |
| smoothing: 0.5, | |
| streamline: 0.5, | |
| })); | |
| context.fillStyle = '#000'; | |
| context.fill(new Path2D(stroke)); | |
| } | |
| }); | |
| break; | |
| case "text": | |
| context.textBaseline = "top"; | |
| context.font = "24px sans-serif"; | |
| context.fillText(element.text, element.x1, element.y1); | |
| break; | |
| case "capture": | |
| context.save(); | |
| context.strokeStyle = "#000000"; | |
| context.setLineDash([5, 5]); | |
| context.lineWidth = 1; | |
| context.strokeRect( | |
| element.x1, | |
| element.y1, | |
| element.x2 - element.x1, | |
| element.y2 - element.y1 | |
| ); | |
| context.restore(); | |
| break; | |
| default: | |
| throw new Error(`Type not recognised: ${element.type}`); | |
| } | |
| }; | |
| export const adjustmentRequired = type => ["line", "rectangle"].includes(type); | |
| export const calculateBoundsForElements = (elementsList) => { | |
| if (elementsList.length === 0) return null; | |
| let minX = Infinity; | |
| let minY = Infinity; | |
| let maxX = -Infinity; | |
| let maxY = -Infinity; | |
| elementsList.forEach(element => { | |
| if (element.type === "text") { | |
| minX = Math.min(minX, element.x1); | |
| minY = Math.min(minY, element.y1); | |
| maxX = Math.max(maxX, element.x2); | |
| maxY = Math.max(maxY, element.y2); | |
| } else if (element.type === "pencil") { | |
| element.points.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 (element.type === "line" || element.type === "rectangle") { | |
| minX = Math.min(minX, element.x1, element.x2); | |
| minY = Math.min(minY, element.y1, element.y2); | |
| maxX = Math.max(maxX, element.x1, element.x2); | |
| maxY = Math.max(maxY, element.y2, element.y2); | |
| } | |
| }); | |
| const padding = 20; | |
| return { | |
| x1: minX - padding, | |
| y1: minY - padding, | |
| x2: maxX + padding, | |
| y2: maxY + padding | |
| }; | |
| }; |