be1f889211
* remove unfinished polygon and remove selectable for shapes in polygon mode * make group and polygon position remain inside canvas area * click through transparent area in grouped object * add some shortcuts for basic usages * tools button icon in center & switch mode border * fix load svg image * basic rtl support, panzoom have issues in rtl mode * better zoom option both in ltr and rtl * handle zoom event in mask editor * add h button to handle toggle mask * add more mime type * use capital M (shift+m) for toggle mask * allow io shortcuts in mask editor only * make other shapes also remain in canvas bound area * better zoom implementation, zoom from center add zoom to resize event listener * add a border to corner to handle blend of control * add refresh button to go to selection menu * add tooltip to shortcuts and also add shortcut for other tools * make opacity remain in same state when toggled on * opacity for group/ungroup objects * update shortcuts implementation
397 lines
12 KiB
TypeScript
397 lines
12 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;
|
|
}
|
|
|
|
async function setupImageOcclusion(setupOptions?: SetupImageOcclusionOptions): Promise<void> {
|
|
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<void> {
|
|
const image = document.querySelector(
|
|
"#image-occlusion-container img",
|
|
) as HTMLImageElement | null;
|
|
if (!image) {
|
|
// error will be handled later
|
|
return;
|
|
}
|
|
|
|
if (image.complete) {
|
|
return;
|
|
}
|
|
|
|
// Wait for the image to load
|
|
await new Promise<void>(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 };
|
|
}
|
|
|
|
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
|
|
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;
|
|
|
|
// setup button for toggle image occlusion
|
|
const button = document.getElementById("toggle");
|
|
if (button) {
|
|
if (document.querySelector("[data-occludeinactive=\"1\"]")) {
|
|
button.addEventListener("click", toggleMasks);
|
|
} else {
|
|
button.style.display = "none";
|
|
}
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
if (event.key === "M") {
|
|
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";
|
|
}
|
|
};
|