anki/ts/image-occlusion/review.ts
Aristotelis c828a2eb6f
Add APIs for IO card rendering (#2739)
* Refactor: Add index to shapes package

* Add shape draw callback API to setupImageCloze

* Expose IO drawing API, switch away from image cloze naming

We currently use "image occlusion" in most places, but some references to "image cloze" still remain. For consistency's sake and to make it easier to quickly find IO-related code, this commit replaces all remaining references to "image cloze", only maintaining those required for backwards compatibility with existing note types.

* Add cloze ordinal to shapes

* Do not mutate original shapes during (de)normalization

Mutating shapes would be a recipe for trouble when combined with IO API use by external consumers.

(makeNormal(makeAbsolute(makeNormal())) is not idempotent,
and keeping track of the original state would introduce
additional complexity with no discernible performance benefit
or otherwise.)

* Tweak IO API, allowing modifications to ShapeProperties

* Tweak drawShape parameters

* Switch method order

For consistency with previous implementation

* Run Rust formatters

* Simplify position (de)normalization

---------

Co-authored-by: Glutanimate <glutanimate@users.noreply.github.com>
2023-10-20 09:36:46 +10:00

310 lines
9.4 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.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();
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();
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: activeShapeBorderWidth ? activeShapeBorderWidth : 1,
color: activeShapeBorderColor
? activeShapeBorderColor
: "#212121",
},
inActiveBorder: {
width: 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";
}
};