c828a2eb6f
* 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>
310 lines
9.4 KiB
TypeScript
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";
|
|
}
|
|
};
|