// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@tslib/ftl"; import { optimumPixelSizeForCanvas } from "./canvas-scale"; import { Shape } from "./shapes"; import { Ellipse, extractShapesFromRenderedClozes, Polygon, Rectangle, Text } from "./shapes"; import { TEXT_BACKGROUND_COLOR, TEXT_FONT_FAMILY, TEXT_PADDING } from "./tools/lib"; import type { Size } from "./types"; export type DrawShapesData = { activeShapes: Shape[]; inactiveShapes: Shape[]; properties: ShapeProperties; }; export type DrawShapesFilter = ( data: DrawShapesData, context: CanvasRenderingContext2D, ) => DrawShapesData | void; export type DrawShapesCallback = ( data: DrawShapesData, context: CanvasRenderingContext2D, ) => void; export const imageOcclusionAPI = { setup: setupImageOcclusion, drawShape, Ellipse, Polygon, Rectangle, Shape, Text, }; interface SetupImageOcclusionOptions { onWillDrawShapes?: DrawShapesFilter; onDidDrawShapes?: DrawShapesCallback; } function setupImageOcclusion(setupOptions?: SetupImageOcclusionOptions): void { window.addEventListener("load", () => { window.addEventListener("resize", () => setupImageOcclusion(setupOptions)); }); window.requestAnimationFrame(() => setupImageOcclusionInner(setupOptions)); } function setupImageOcclusionInner(setupOptions?: SetupImageOcclusionOptions): void { const canvas = document.querySelector( "#image-occlusion-canvas", ) as HTMLCanvasElement | null; if (canvas == null) { return; } const container = document.getElementById( "image-occlusion-container", ) as HTMLDivElement; const image = document.querySelector( "#image-occlusion-container img", ) as HTMLImageElement; if (image == null) { container.innerText = tr.notetypeErrorNoImageToShow(); return; } // Enforce aspect ratio of image container.style.aspectRatio = `${image.naturalWidth / image.naturalHeight}`; const size = optimumPixelSizeForCanvas( { width: image.naturalWidth, height: image.naturalHeight }, { width: canvas.clientWidth, height: canvas.clientHeight }, ); canvas.width = size.width; canvas.height = size.height; // setup button for toggle image occlusion const button = document.getElementById("toggle"); if (button) { button.addEventListener("click", toggleMasks); } drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes); } function drawShapes( canvas: HTMLCanvasElement, onWillDrawShapes?: DrawShapesFilter, onDidDrawShapes?: DrawShapesCallback, ): void { const context: CanvasRenderingContext2D = canvas.getContext("2d")!; const size = canvas; let activeShapes = extractShapesFromRenderedClozes(".cloze"); let inactiveShapes = extractShapesFromRenderedClozes(".cloze-inactive"); let properties = getShapeProperties(); const processed = onWillDrawShapes?.({ activeShapes, inactiveShapes, properties }, context); if (processed) { activeShapes = processed.activeShapes; inactiveShapes = processed.inactiveShapes; properties = processed.properties; } for (const shape of activeShapes) { drawShape({ context, size, shape, fill: properties.activeShapeColor, stroke: properties.activeBorder.color, strokeWidth: properties.activeBorder.width, }); } for (const shape of inactiveShapes.filter((s) => s.occludeInactive)) { drawShape({ context, size, shape, fill: properties.inActiveShapeColor, stroke: properties.inActiveBorder.color, strokeWidth: properties.inActiveBorder.width, }); } onDidDrawShapes?.({ activeShapes, inactiveShapes, properties }, context); } interface DrawShapeParameters { context: CanvasRenderingContext2D; size: Size; shape: Shape; fill: string; stroke: string; strokeWidth: number; } function drawShape({ context: ctx, size, shape, fill, stroke, strokeWidth, }: DrawShapeParameters): void { shape = shape.toAbsolute(size); ctx.fillStyle = fill; ctx.strokeStyle = stroke; ctx.lineWidth = strokeWidth; if (shape instanceof Rectangle) { ctx.fillRect(shape.left, shape.top, shape.width, shape.height); // ctx stroke methods will draw a visible stroke, even if the width is 0 if (strokeWidth) { ctx.strokeRect(shape.left, shape.top, shape.width, shape.height); } } else if (shape instanceof Ellipse) { const adjustedLeft = shape.left + shape.rx; const adjustedTop = shape.top + shape.ry; ctx.beginPath(); ctx.ellipse( adjustedLeft, adjustedTop, shape.rx, shape.ry, 0, 0, Math.PI * 2, false, ); ctx.closePath(); ctx.fill(); if (strokeWidth) { ctx.stroke(); } } else if (shape instanceof Polygon) { const offset = getPolygonOffset(shape); ctx.save(); ctx.translate(offset.x, offset.y); ctx.beginPath(); ctx.moveTo(shape.points[0].x, shape.points[0].y); for (let i = 0; i < shape.points.length; i++) { ctx.lineTo(shape.points[i].x, shape.points[i].y); } ctx.closePath(); ctx.fill(); if (strokeWidth) { ctx.stroke(); } ctx.restore(); } else if (shape instanceof Text) { ctx.save(); ctx.font = `40px ${TEXT_FONT_FAMILY}`; ctx.textBaseline = "top"; ctx.scale(shape.scaleX, shape.scaleY); const textMetrics = ctx.measureText(shape.text); ctx.fillStyle = TEXT_BACKGROUND_COLOR; ctx.fillRect( shape.left / shape.scaleX, shape.top / shape.scaleY, textMetrics.width + TEXT_PADDING, textMetrics.actualBoundingBoxDescent + TEXT_PADDING, ); ctx.fillStyle = "#000"; ctx.fillText( shape.text, shape.left / shape.scaleX, shape.top / shape.scaleY, ); ctx.restore(); } } function getPolygonOffset(polygon: Polygon): { x: number; y: number } { const topLeft = topLeftOfPoints(polygon.points); return { x: polygon.left - topLeft.x, y: polygon.top - topLeft.y }; } function topLeftOfPoints(points: { x: number; y: number }[]): { x: number; y: number; } { let top = points[0].y; let left = points[0].x; for (const point of points) { if (point.y < top) { top = point.y; } if (point.x < left) { left = point.x; } } return { x: left, y: top }; } export type ShapeProperties = { activeShapeColor: string; inActiveShapeColor: string; activeBorder: { width: number; color: string }; inActiveBorder: { width: number; color: string }; }; function getShapeProperties(): ShapeProperties { const canvas = document.getElementById("image-occlusion-canvas"); const computedStyle = window.getComputedStyle(canvas!); // it may throw error if the css variable is not defined try { // shape color const activeShapeColor = computedStyle.getPropertyValue( "--active-shape-color", ); const inActiveShapeColor = computedStyle.getPropertyValue( "--inactive-shape-color", ); // inactive shape border const inActiveShapeBorder = computedStyle.getPropertyValue( "--inactive-shape-border", ); const inActiveBorder = inActiveShapeBorder.split(" ").filter((x) => x); const inActiveShapeBorderWidth = parseFloat(inActiveBorder[0]); const inActiveShapeBorderColor = inActiveBorder[1]; // active shape border const activeShapeBorder = computedStyle.getPropertyValue( "--active-shape-border", ); const activeBorder = activeShapeBorder.split(" ").filter((x) => x); const activeShapeBorderWidth = parseFloat(activeBorder[0]); const activeShapeBorderColor = activeBorder[1]; return { activeShapeColor: activeShapeColor ? activeShapeColor : "#ff8e8e", inActiveShapeColor: inActiveShapeColor ? inActiveShapeColor : "#ffeba2", activeBorder: { width: !isNaN(activeShapeBorderWidth) ? activeShapeBorderWidth : 1, color: activeShapeBorderColor ? activeShapeBorderColor : "#212121", }, inActiveBorder: { width: !isNaN(inActiveShapeBorderWidth) ? inActiveShapeBorderWidth : 1, color: inActiveShapeBorderColor ? inActiveShapeBorderColor : "#212121", }, }; } catch { // return default values return { activeShapeColor: "#ff8e8e", inActiveShapeColor: "#ffeba2", activeBorder: { width: 1, color: "#212121", }, inActiveBorder: { width: 1, color: "#212121", }, }; } } const toggleMasks = (): void => { const canvas = document.getElementById( "image-occlusion-canvas", ) as HTMLCanvasElement; const display = canvas.style.display; if (display === "none") { canvas.style.display = "unset"; } else { canvas.style.display = "none"; } };