anki/ts/image-occlusion/review.ts
Aristotelis 8e828f2955
Allow disabling occlusion mask border (#2764)
* Allow setting occlusion mask border to zero

* Switch to multi-line if statements

cf. 9740393d72

* Enforce if statement braces in dprint

---------

Co-authored-by: Glutanimate <glutanimate@users.noreply.github.com>
2023-10-23 11:43:37 +10:00

317 lines
9.6 KiB
TypeScript

// 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";
}
};