// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { ModuleName, setupI18n } from "@tslib/i18n"; 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[]; highlightShapes: 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; } async function setupImageOcclusion(setupOptions?: SetupImageOcclusionOptions): Promise { await waitForImage(); window.addEventListener("load", () => { window.addEventListener("resize", () => setupImageOcclusion(setupOptions)); }); window.requestAnimationFrame(() => setupImageOcclusionInner(setupOptions)); } /** We must make sure the image has loaded before we can access its dimensions. * This can happen if not preloading, or if preloading takes too long. */ async function waitForImage(): Promise { const image = document.querySelector( "#image-occlusion-container img", ); if (!image) { // error will be handled later return; } if (image.complete) { return; } // Wait for the image to load await new Promise(resolve => { image.addEventListener("load", () => { resolve(); }); image.addEventListener("error", () => { resolve(); }); }); } /** * Calculate the size of the container that will fit in the viewport while having * the same aspect ratio as the image. This is a workaround for Qt5 WebEngine not * supporting the `aspect-ratio` CSS property. */ function calculateContainerSize( container: HTMLDivElement, img: HTMLImageElement, ): { width: number; height: number } { const compStyle = getComputedStyle(container); const compMaxWidth = parseFloat(compStyle.getPropertyValue("max-width")); const vw = container.parentElement!.clientWidth; // respect 'max-width' if it is set narrower than the viewport const maxWidth = Number.isNaN(compMaxWidth) || compMaxWidth > vw ? vw : compMaxWidth; // see ./review.scss const defaultMaxHeight = document.documentElement.clientHeight * 0.95 - 40; const compMaxHeight = parseFloat(compStyle.getPropertyValue("max-height")); let maxHeight: number; // If 'max-height' is set to 'unset' or 'initial' and the image is taller than // the default max height, the container height is up to the image height. if (Number.isNaN(compMaxHeight)) { maxHeight = Math.max(img.naturalHeight, defaultMaxHeight); } else if (compMaxHeight < defaultMaxHeight) { maxHeight = compMaxHeight; } else { maxHeight = Math.max(defaultMaxHeight, Math.min(img.naturalHeight, compMaxHeight)); } const ratio = Math.min( maxWidth / img.naturalWidth, maxHeight / img.naturalHeight, ); return { width: img.naturalWidth * ratio, height: img.naturalHeight * ratio }; } let oneTimeSetupDone = false; async function setupImageOcclusionInner(setupOptions?: SetupImageOcclusionOptions): Promise { const canvas = document.querySelector( "#image-occlusion-canvas", ); if (canvas == null) { return; } const container = document.getElementById( "image-occlusion-container", ) as HTMLDivElement; const image = document.querySelector( "#image-occlusion-container img", ); if (image == null) { await setupI18n({ modules: [ ModuleName.NOTETYPES, ], }); container.innerText = tr.notetypeErrorNoImageToShow(); return; } // Enforce aspect ratio of image if (CSS.supports("aspect-ratio: 1")) { container.style.aspectRatio = `${image.naturalWidth / image.naturalHeight}`; } else { const containerSize = calculateContainerSize(container, image); container.style.width = `${containerSize.width}px`; container.style.height = `${containerSize.height}px`; } const size = optimumPixelSizeForCanvas( { width: image.naturalWidth, height: image.naturalHeight }, { width: canvas.clientWidth, height: canvas.clientHeight }, ); canvas.width = size.width; canvas.height = size.height; if (!oneTimeSetupDone) { window.addEventListener("keydown", (event) => { const button = document.getElementById("toggle"); if (button && button.style.display !== "none" && event.key === "M") { toggleMasks(setupOptions); } }); oneTimeSetupDone = true; } // setup button for toggle image occlusion const button = document.getElementById("toggle"); if (button) { if (document.querySelector("[data-occludeinactive=\"1\"]")) { button.addEventListener("click", () => toggleMasks(setupOptions)); } else { button.style.display = "none"; } } drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes); } function drawShapes( canvas: HTMLCanvasElement, onWillDrawShapes?: DrawShapesFilter, onDidDrawShapes?: DrawShapesCallback, allowedShapes?: Array, ): void { const context: CanvasRenderingContext2D = canvas.getContext("2d")!; const size = canvas; const filterByAllowedShapes = (el: Shape) => (allowedShapes && allowedShapes.length > 0) ? allowedShapes.some(s => el instanceof s) : true; let activeShapes = extractShapesFromRenderedClozes(".cloze").filter(filterByAllowedShapes); let inactiveShapes = extractShapesFromRenderedClozes(".cloze-inactive").filter(filterByAllowedShapes); let highlightShapes = extractShapesFromRenderedClozes(".cloze-highlight").filter(filterByAllowedShapes); let properties = getShapeProperties(); const processed = onWillDrawShapes?.({ activeShapes, inactiveShapes, highlightShapes, properties }, context); if (processed) { activeShapes = processed.activeShapes; inactiveShapes = processed.inactiveShapes; highlightShapes = processed.highlightShapes; 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: shape.fill ?? properties.inActiveShapeColor, stroke: properties.inActiveBorder.color, strokeWidth: properties.inActiveBorder.width, }); } for (const shape of highlightShapes) { drawShape({ context, size, shape, fill: properties.highlightShapeColor, stroke: properties.highlightShapeBorder.color, strokeWidth: properties.highlightShapeBorder.width, }); } onDidDrawShapes?.({ activeShapes, inactiveShapes, highlightShapes, 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; const angle = ((shape.angle ?? 0) * Math.PI) / 180; if (shape instanceof Rectangle) { if (angle) { ctx.save(); ctx.translate(shape.left, shape.top); ctx.rotate(angle); ctx.translate(-shape.left, -shape.top); } 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); } if (angle) { ctx.restore(); } } else if (shape instanceof Ellipse) { const adjustedLeft = shape.left + shape.rx; const adjustedTop = shape.top + shape.ry; if (angle) { ctx.save(); ctx.translate(shape.left, shape.top); ctx.rotate(angle); ctx.translate(-shape.left, -shape.top); } ctx.beginPath(); ctx.ellipse( adjustedLeft, adjustedTop, shape.rx, shape.ry, 0, 0, Math.PI * 2, false, ); ctx.closePath(); ctx.fill(); if (strokeWidth) { ctx.stroke(); } if (angle) { ctx.restore(); } } 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 = `${shape.fontSize}px ${TEXT_FONT_FAMILY}`; ctx.textBaseline = "top"; ctx.scale(shape.scaleX, shape.scaleY); const lines = shape.text.split("\n"); const baseMetrics = ctx.measureText("M"); const fontHeight = baseMetrics.actualBoundingBoxAscent + baseMetrics.actualBoundingBoxDescent; const lineHeight = 1.5 * fontHeight; const linePositions: { text: string; x: number; y: number; width: number; height: number }[] = []; let maxWidth = 0; let totalHeight = 0; for (let i = 0; i < lines.length; i++) { const textMetrics = ctx.measureText(lines[i]); linePositions.push({ text: lines[i], x: shape.left / shape.scaleX, y: shape.top / shape.scaleY + i * lineHeight, width: textMetrics.width, height: lineHeight, }); if (textMetrics.width > maxWidth) { maxWidth = textMetrics.width; } totalHeight += lineHeight; } const left = shape.left / shape.scaleX; const top = shape.top / shape.scaleY; if (angle) { ctx.translate(left, top); ctx.rotate(angle); ctx.translate(-left, -top); } ctx.fillStyle = TEXT_BACKGROUND_COLOR; ctx.fillRect( left, top, maxWidth + TEXT_PADDING, totalHeight + TEXT_PADDING, ); ctx.fillStyle = shape.fill ?? "#000"; for (const line of linePositions) { ctx.fillText(line.text, line.x, line.y); } 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; highlightShapeColor: string; activeBorder: { width: number; color: string }; inActiveBorder: { width: number; color: string }; highlightShapeBorder: { 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", ); const highlightShapeColor = computedStyle.getPropertyValue( "--highlight-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]; // highlight shape border const highlightShapeBorder = computedStyle.getPropertyValue( "--highlight-shape-border", ); const highlightBorder = highlightShapeBorder.split(" ").filter((x) => x); const highlightShapeBorderWidth = parseFloat(highlightBorder[0]); const highlightShapeBorderColor = highlightBorder[1]; return { activeShapeColor: activeShapeColor ? activeShapeColor : "#ff8e8e", inActiveShapeColor: inActiveShapeColor ? inActiveShapeColor : "#ffeba2", highlightShapeColor: highlightShapeColor ? highlightShapeColor : "#ff8e8e00", activeBorder: { width: !isNaN(activeShapeBorderWidth) ? activeShapeBorderWidth : 1, color: activeShapeBorderColor ? activeShapeBorderColor : "#212121", }, inActiveBorder: { width: !isNaN(inActiveShapeBorderWidth) ? inActiveShapeBorderWidth : 1, color: inActiveShapeBorderColor ? inActiveShapeBorderColor : "#212121", }, highlightShapeBorder: { width: !isNaN(highlightShapeBorderWidth) ? highlightShapeBorderWidth : 1, color: highlightShapeBorderColor ? highlightShapeBorderColor : "#ff8e8e", }, }; } catch { // return default values return { activeShapeColor: "#ff8e8e", inActiveShapeColor: "#ffeba2", highlightShapeColor: "#ff8e8e00", activeBorder: { width: 1, color: "#212121", }, inActiveBorder: { width: 1, color: "#212121", }, highlightShapeBorder: { width: 1, color: "#ff8e8e", }, }; } } let hide = false; const toggleMasks = (setupOptions?: SetupImageOcclusionOptions): void => { const canvas = document.getElementById("image-occlusion-canvas") as HTMLCanvasElement; const context = canvas.getContext("2d")!; hide = !hide; context.clearRect(0, 0, canvas.width, canvas.height); if (hide) { drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes, [Text]); return; } drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes); };