Various changes to I/O handling (#2513)

* Store coordinates as ratios of full size

* Use single definition for cappedCanvasSize()

* Move I/O review code into ts/image-occlusion

A bit simpler when it's all in one place.

* Reduce number precision, and round to whole pixels

>>> n=10000
>>> for i in range(1, int(n)): assert i == round(float("%0.4f" % (i/n))*n)

* Minor typing tweak

So, it turns out that typing is mostly broken in ts/image-occlusion.
We're importing from fabric which is a js file without types, so types
like fabric.Canvas are resolving to any.

I first tried switching to `@types/fabric`, which introduced a slew of
typing errors. Wasted a few hours trying to address them, before deciding
to give up on it, since the types were not complete. Then found fabric
has a 6.0 beta that introduces typing, and spent some time with that, but
ran into some new issues as it still seems to be a work in progress.
I think we're probably best off waiting until it's out and stabilized
before sinking more effort into this.

* Refactor (de)serialization of occlusions

To make the code easier to follow/maintain, cloze deletions are now decoded/
encoded into simple data classes, which can then be converted to Fabric objects
and back. The data objects handle converting from absolute/normal positions, and
producing values suitable for writing to text (eg truncated floats).

Various other changes:

- Polygon points are now stored as 'x,y x2,y2 ...' instead of JSON in cloze
divs, as that makes the handling consistent with reading from cloze deletion
text.
- Fixed the reviewer not showing updated placement when a polygon was moved.
- Disabled rotation controls in the editor, since we don't support rotation during
review.
- Renamed hideInactive to occludeInactive, as it wasn't clear whether the former
meant to hide the occlusions, or keep them (hiding the content). It's stored
as 'oi=1' in the cloze text.

* Increase canvas size limit, and double pixels when required.

* Size canvas based on container size

This results in sharper masks when the intrinsic image size is smaller
than the container, and more legible ones when the container is smaller than
the intrinsic image size.

By using the container instead of the viewport, we account for margins,
and when the pixel ratio is 1x, the canvas size and container size should
match.

* Disable zoom animation on editor load

* Default to rectangle when adding new occlusions

* Allow users to add/update notes directly from mask editing page

* The mask editor needs to work with css pixels, not actual pixels

The canvas and image were being scaled too large, which impacted
performance.
This commit is contained in:
Damien Elmes 2023-05-31 13:45:12 +10:00 committed by GitHub
parent c87f62487b
commit 7f6c410ca5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 809 additions and 451 deletions

View File

@ -394,7 +394,7 @@ fn build_and_check_editor(build: &mut Build) -> Result<()> {
}
fn build_and_check_reviewer(build: &mut Build) -> Result<()> {
let reviewer_deps = inputs![":ts:lib", glob!("ts/reviewer/**"),];
let reviewer_deps = inputs![":ts:lib", glob!("ts/{reviewer,image-occlusion}/**"),];
build.add(
"ts:reviewer:reviewer.js",
EsbuildScript {

View File

@ -3,10 +3,11 @@
use std::fmt::Write;
// split following
// text = "rect:399.01,99.52,167.09,33.78:fill=#0a2cee:stroke=1"
// with
// result = "data-shape="rect" data-left="399.01" data-top="99.52" data-width="167.09" data-height="33.78" data-fill="\#0a2cee" data-stroke="1""
// convert text like
// rect:left=.2325:top=.3261:width=.202:height=.0975
// to something like
// result = "data-shape="rect" data-left="399.01" data-top="99.52"
// data-width="167.09" data-height="33.78"
pub fn get_image_cloze_data(text: &str) -> String {
let mut result = String::new();
let parts: Vec<&str> = text.split(':').collect();
@ -57,18 +58,18 @@ pub fn get_image_cloze_data(text: &str) -> String {
let mut point_str = String::new();
for point_pair in values[1].split(' ') {
let Some((x, y)) = point_pair.split_once(',') else { continue };
write!(&mut point_str, "[{},{}],", x, y).unwrap();
write!(&mut point_str, "{},{} ", x, y).unwrap();
}
// remove the trailing comma
// remove the trailing space
point_str.pop();
if !point_str.is_empty() {
result.push_str(&format!("data-points=\"[{}]\" ", point_str));
result.push_str(&format!("data-points=\"{point_str}\" "));
}
}
}
"hideinactive" => {
"oi" => {
if !values[1].is_empty() {
result.push_str(&format!("data-hideinactive=\"{}\" ", values[1]));
result.push_str(&format!("data-occludeInactive=\"{}\" ", values[1]));
}
}
_ => {}
@ -102,6 +103,6 @@ fn test_get_image_cloze_data() {
);
assert_eq!(
get_image_cloze_data("polygon:points=0,0 10,10 20,0"),
r#"data-shape="polygon" data-points="[[0,0],[10,10],[20,0]]" "#,
r#"data-shape="polygon" data-points="0,0 10,10 20,0" "#,
);
}

View File

@ -6,7 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "@tslib/ftl";
import Container from "../components/Container.svelte";
import { addOrUpdateNote } from "./generate";
import { addOrUpdateNote } from "./add-or-update-note";
import type { IOMode } from "./lib";
import MasksEditor from "./MaskEditor.svelte";
import Notes from "./Notes.svelte";
@ -15,11 +15,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let mode: IOMode;
async function hideAllGuessOne(): Promise<void> {
addOrUpdateNote(mode, false);
addOrUpdateNote(mode, true);
}
async function hideOneGuessOne(): Promise<void> {
addOrUpdateNote(mode, true);
addOrUpdateNote(mode, false);
}
const items = [
@ -50,8 +50,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div hidden={activeTabValue != 2}>
<Notes />
<StickyFooter {hideAllGuessOne} {hideOneGuessOne} />
</div>
<StickyFooter {hideAllGuessOne} {hideOneGuessOne} />
</Container>
<style lang="scss">

View File

@ -14,9 +14,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let instance: PanZoom;
let innerWidth = 0;
const startingTool = mode.kind === "add" ? "draw-rectangle" : "cursor";
$: canvas = null;
function initPanzoom(node) {
function init(node) {
instance = panzoom(node, {
bounds: true,
maxZoom: 3,
@ -38,9 +39,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
</script>
<SideToolbar {instance} {canvas} />
<SideToolbar {instance} {canvas} activeTool={startingTool} />
<div class="editor-main" bind:clientWidth={innerWidth}>
<div class="editor-container" use:initPanzoom>
<div class="editor-container" use:init>
<!-- svelte-ignore a11y-missing-attribute -->
<img id="image" />
<canvas id="canvas" />

View File

@ -14,14 +14,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const iconSize = 80;
let activeTool = "cursor";
export let activeTool = "cursor";
function setActive(toolId) {
activeTool = toolId;
// handle tool changes after initialization
$: if (instance && canvas) {
disableFunctions();
enableSelectable(canvas, true);
switch (toolId) {
switch (activeTool) {
case "magnify":
enableSelectable(canvas, false);
instance.resume();
@ -56,7 +56,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{iconSize}
active={activeTool === tool.id}
on:click={() => {
setActive(tool.id);
activeTool = tool.id;
}}>{@html tool.icon}</IconButton
>
{/each}

View File

@ -0,0 +1,70 @@
// 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 { get } from "svelte/store";
import type { Collection } from "../lib/proto";
import type { IOMode } from "./lib";
import { addImageOcclusionNote, updateImageOcclusionNote } from "./lib";
import { exportShapesToClozeDeletions } from "./shapes/to-cloze";
import { notesDataStore, tagsWritable } from "./store";
import Toast from "./Toast.svelte";
export const addOrUpdateNote = async function(
mode: IOMode,
occludeInactive: boolean,
): Promise<void> {
const { clozes: occlusionCloze, noteCount } = exportShapesToClozeDeletions(occludeInactive);
if (noteCount === 0) {
return;
}
const fieldsData: { id: string; title: string; divValue: string; textareaValue: string }[] = get(notesDataStore);
const tags = get(tagsWritable);
let header = fieldsData[0].textareaValue;
let backExtra = fieldsData[1].textareaValue;
header = header ? `<div>${header}</div>` : "";
backExtra = header ? `<div>${backExtra}</div>` : "";
if (mode.kind == "edit") {
const result = await updateImageOcclusionNote(
mode.noteId,
occlusionCloze,
header,
backExtra,
tags,
);
showResult(mode.noteId, result, noteCount);
} else {
const result = await addImageOcclusionNote(
mode.notetypeId,
mode.imagePath,
occlusionCloze,
header,
backExtra,
tags,
);
showResult(null, result, noteCount);
}
};
// show toast message
const showResult = (noteId: number | null, result: Collection.OpChanges, count: number) => {
const toastComponent = new Toast({
target: document.body,
props: {
message: "",
type: "error",
},
});
if (result.note) {
const msg = noteId ? tr.browsingCardsUpdated({ count: count }) : tr.importingCardsAdded({ count: count });
toastComponent.$set({ message: msg, type: "success", showToast: true });
} else {
const msg = tr.notetypesErrorGeneratingCloze();
toastComponent.$set({ message: msg, showToast: true });
}
};

View File

@ -0,0 +1,48 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Size } from "./types";
/**
* - Choose an appropriate size for the canvas based on the current container,
* so the masks are sharp and legible.
* - Safari doesn't allow canvas elements to be over 16M (4096x4096), so we need
* to ensure the canvas is smaller than that size.
* - Returns the size in actual pixels, not CSS size.
*/
export function optimumPixelSizeForCanvas(imageSize: Size, containerSize: Size): Size {
let { width, height } = imageSize;
const pixelScale = window.devicePixelRatio;
containerSize.width *= pixelScale;
containerSize.height *= pixelScale;
// Scale image dimensions to fit in container, retaining aspect ratio.
// We take the minimum of width/height scales, as that's the one that is
// potentially limiting the image from expanding.
const containerScale = Math.min(containerSize.width / imageSize.width, containerSize.height / imageSize.height);
width *= containerScale;
height *= containerScale;
const maximumPixels = 4096 * 4096;
const requiredPixels = width * height;
if (requiredPixels > maximumPixels) {
const shrinkScale = Math.sqrt(maximumPixels) / Math.sqrt(requiredPixels);
width *= shrinkScale;
height *= shrinkScale;
}
return {
width: Math.floor(width),
height: Math.floor(height),
};
}
/** See {@link optimumPixelSizeForCanvas()} */
export function optimumCssSizeForCanvas(imageSize: Size, containerSize: Size): Size {
const { width, height } = optimumPixelSizeForCanvas(imageSize, containerSize);
return {
width: width / window.devicePixelRatio,
height: height / window.devicePixelRatio,
};
}

View File

@ -1,167 +0,0 @@
// 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 { fabric } from "fabric";
import { get } from "svelte/store";
import type { Collection } from "../lib/proto";
import type { IOMode } from "./lib";
import { addImageOcclusionNote, updateImageOcclusionNote } from "./lib";
import { notesDataStore, tagsWritable } from "./store";
import Toast from "./Toast.svelte";
import { makeMaskTransparent } from "./tools/lib";
const divData = [
"height",
"left",
"top",
"type",
"width",
];
// Defines the number of fraction digits to use when serializing object values
fabric.Object.NUM_FRACTION_DIGITS = 2;
export function generate(hideInactive: boolean): { occlusionCloze: string; noteCount: number } {
const canvas = globalThis.canvas;
const canvasObjects = canvas.getObjects();
if (canvasObjects.length < 1) {
return { occlusionCloze: "", noteCount: 0 };
}
let occlusionCloze = "";
let clozeData = "";
let noteCount = 0;
makeMaskTransparent(canvas, false);
canvasObjects.forEach((object, index) => {
const obJson = object.toJSON();
noteCount++;
if (obJson.type === "group") {
clozeData += getGroupCloze(object, index, hideInactive);
} else {
clozeData += getCloze(object, index, null, hideInactive);
}
});
occlusionCloze += clozeData;
return { occlusionCloze, noteCount };
}
const getCloze = (object, index, relativePos, hideInactive): string => {
const obJson = object.toJSON();
let clozeData = "";
// generate cloze data in form of
// {{c1::image-occlusion:rect:top=100:left=100:width=100:height=100}}
Object.keys(obJson).forEach(function(key) {
if (divData.includes(key)) {
if (key === "type") {
clozeData += `:${obJson[key]}`;
if (obJson[key] === "ellipse") {
clozeData += `:rx=${obJson.rx.toFixed(2)}:ry=${obJson.ry.toFixed(2)}`;
}
if (obJson[key] === "polygon") {
const points = obJson.points;
let pnts = "";
points.forEach((point: { x: number; y: number }) => {
pnts += point.x.toFixed(2) + "," + point.y.toFixed(2) + " ";
});
clozeData += `:points=${pnts.trim()}`;
}
} else if (relativePos && key === "top") {
clozeData += `:top=${relativePos.top}`;
} else if (relativePos && key === "left") {
clozeData += `:left=${relativePos.left}`;
} else {
clozeData += `:${key}=${obJson[key]}`;
}
}
});
clozeData += `:hideinactive=${hideInactive}`;
clozeData = `{{c${index + 1}::image-occlusion${clozeData}}}<br>`;
return clozeData;
};
const getGroupCloze = (group, index, hideInactive): string => {
let clozeData = "";
const objects = group._objects;
objects.forEach((object) => {
const { top, left } = getObjectPositionInGroup(group, object);
clozeData += getCloze(object, index, { top, left }, hideInactive);
});
return clozeData;
};
const getObjectPositionInGroup = (group, object): { top: number; left: number } => {
let left = object.left + group.left + group.width / 2;
let top = object.top + group.top + group.height / 2;
left = left.toFixed(2);
top = top.toFixed(2);
return { top, left };
};
export const addOrUpdateNote = async function(
mode: IOMode,
hideInactive: boolean,
): Promise<void> {
const { occlusionCloze, noteCount } = generate(hideInactive);
if (noteCount === 0) {
return;
}
const fieldsData: { id: string; title: string; divValue: string; textareaValue: string }[] = get(notesDataStore);
const tags = get(tagsWritable);
let header = fieldsData[0].textareaValue;
let backExtra = fieldsData[1].textareaValue;
header = header ? `<div>${header}</div>` : "";
backExtra = header ? `<div>${backExtra}</div>` : "";
if (mode.kind == "edit") {
const result = await updateImageOcclusionNote(
mode.noteId,
occlusionCloze,
header,
backExtra,
tags,
);
showResult(mode.noteId, result, noteCount);
} else {
const result = await addImageOcclusionNote(
mode.notetypeId,
mode.imagePath,
occlusionCloze,
header,
backExtra,
tags,
);
showResult(null, result, noteCount);
}
};
// show toast message
const showResult = (noteId: number | null, result: Collection.OpChanges, count: number) => {
const toastComponent = new Toast({
target: document.body,
props: {
message: "",
type: "error",
},
});
if (result.note) {
const msg = noteId ? tr.browsingCardsUpdated({ count: count }) : tr.importingCardsAdded({ count: count });
toastComponent.$set({ message: msg, type: "success", showToast: true });
} else {
const msg = tr.notetypesErrorGeneratingCloze();
toastComponent.$set({ message: msg, showToast: true });
}
};

View File

@ -8,12 +8,14 @@ import type { PanZoom } from "panzoom";
import protobuf from "protobufjs";
import { get } from "svelte/store";
import { optimumCssSizeForCanvas } from "./canvas-scale";
import { getImageForOcclusion, getImageOcclusionNote } from "./lib";
import { notesDataStore, tagsWritable, zoomResetValue } from "./store";
import Toast from "./Toast.svelte";
import { addShapesToCanvasFromCloze } from "./tools/add-from-cloze";
import { enableSelectable, moveShapeToCanvasBoundaries } from "./tools/lib";
import { generateShapeFromCloze } from "./tools/shape-generate";
import { undoRedoInit } from "./tools/tool-undo-redo";
import type { Size } from "./types";
export const setupMaskEditor = async (path: string, instance: PanZoom): Promise<fabric.Canvas> => {
const imageData = await getImageForOcclusion(path!);
@ -23,7 +25,7 @@ export const setupMaskEditor = async (path: string, instance: PanZoom): Promise<
const image = document.getElementById("image") as HTMLImageElement;
image.src = getImageData(imageData.data!);
image.onload = function() {
const size = limitSize({ width: image.width, height: image.height });
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
canvas.setWidth(size.width);
canvas.setHeight(size.height);
image.height = size.height;
@ -54,14 +56,14 @@ export const setupMaskEditorForEdit = async (noteId: number, instance: PanZoom):
const image = document.getElementById("image") as HTMLImageElement;
image.src = getImageData(clozeNote.imageData!);
image.onload = function() {
const size = limitSize({ width: image.width, height: image.height });
const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());
canvas.setWidth(size.width);
canvas.setHeight(size.height);
image.height = size.height;
image.width = size.width;
setCanvasZoomRatio(canvas, instance);
generateShapeFromCloze(canvas, clozeNote.occlusions);
addShapesToCanvasFromCloze(canvas, clozeNote.occlusions);
enableSelectable(canvas, true);
addClozeNotesToTextEditor(clozeNote.header, clozeNote.backExtra, clozeNote.tags);
};
@ -98,7 +100,7 @@ const setCanvasZoomRatio = (
const zoomRatioH = (innerHeight - 100) / canvas.height!;
const zoomRatio = zoomRatioW < zoomRatioH ? zoomRatioW : zoomRatioH;
zoomResetValue.set(zoomRatio);
instance.smoothZoom(0, 0, zoomRatio);
instance.zoomAbs(0, 0, zoomRatio);
};
const addClozeNotesToTextEditor = (header: string, backExtra: string, tags: string[]) => {
@ -121,23 +123,10 @@ const addClozeNotesToTextEditor = (header: string, backExtra: string, tags: stri
});
};
/**
* Fix for safari browser,
* Canvas area exceeds the maximum limit (width * height > 16777216),
* Following function also added in reviewer ts,
* so update both, if it changes
*/
const limitSize = (size: { width: number; height: number }): { width: number; height: number; scalar: number } => {
const maximumPixels = 1000000;
const { width, height } = size;
const requiredPixels = width * height;
if (requiredPixels <= maximumPixels) return { width, height, scalar: 1 };
const scalar = Math.sqrt(maximumPixels) / Math.sqrt(requiredPixels);
function containerSize(): Size {
const container = document.querySelector(".editor-main")!;
return {
width: Math.floor(width * scalar),
height: Math.floor(height * scalar),
scalar: scalar,
width: container.clientWidth,
height: container.clientHeight,
};
};
}

View File

@ -3,11 +3,18 @@
import * as tr from "@tslib/ftl";
window.addEventListener("load", () => {
window.addEventListener("resize", setupImageCloze);
});
import { optimumPixelSizeForCanvas } from "./canvas-scale";
import type { Shape } from "./shapes/base";
import { Ellipse } from "./shapes/ellipse";
import { extractShapesFromRenderedClozes } from "./shapes/from-cloze";
import { Polygon } from "./shapes/polygon";
import { Rectangle } from "./shapes/rectangle";
import type { Size } from "./types";
export function setupImageCloze(): void {
window.addEventListener("load", () => {
window.addEventListener("resize", setupImageCloze);
});
window.requestAnimationFrame(setupImageClozeInner);
}
@ -25,121 +32,95 @@ function setupImageClozeInner(): void {
return;
}
const size = limitSize({ width: image.naturalWidth, height: image.naturalHeight });
// 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;
// Enforce aspect ratio of image
container.style.aspectRatio = `${size.width / size.height}`;
// setup button for toggle image occlusion
const button = document.getElementById("toggle");
if (button) {
button.addEventListener("click", toggleMasks);
}
drawShapes(ctx);
drawShapes(canvas, ctx);
}
function drawShapes(ctx: CanvasRenderingContext2D): void {
const activeCloze = document.querySelectorAll(".cloze");
const inActiveCloze = document.querySelectorAll(".cloze-inactive");
function drawShapes(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
const shapeProperty = getShapeProperty();
for (const clz of activeCloze) {
const cloze = (<HTMLDivElement> clz);
const shape = cloze.dataset.shape!;
const size = canvas;
for (const active of extractShapesFromRenderedClozes(".cloze")) {
const fill = shapeProperty.activeShapeColor;
draw(ctx, cloze, shape, fill, shapeProperty.activeBorder);
drawShape(ctx, size, active, fill, shapeProperty.activeBorder);
}
for (const clz of inActiveCloze) {
const cloze = (<HTMLDivElement> clz);
const shape = cloze.dataset.shape!;
for (const inactive of extractShapesFromRenderedClozes(".cloze-inactive")) {
const fill = shapeProperty.inActiveShapeColor;
const hideinactive = cloze.dataset.hideinactive == "true";
if (!hideinactive) {
draw(ctx, cloze, shape, fill, shapeProperty.inActiveBorder);
if (inactive.occludeInactive) {
drawShape(ctx, size, inactive, fill, shapeProperty.inActiveBorder);
}
}
}
function draw(
function drawShape(
ctx: CanvasRenderingContext2D,
cloze: HTMLDivElement,
shape: string,
size: Size,
shape: Shape,
color: string,
border: { width: number; color: string },
): void {
shape.makeAbsolute(size);
ctx.fillStyle = color;
const posLeft = parseFloat(cloze.dataset.left!);
const posTop = parseFloat(cloze.dataset.top!);
const width = parseFloat(cloze.dataset.width!);
const height = parseFloat(cloze.dataset.height!);
switch (shape) {
case "rect":
{
ctx.strokeStyle = border.color;
ctx.lineWidth = border.width;
ctx.fillRect(posLeft, posTop, width, height);
ctx.strokeRect(posLeft, posTop, width, height);
}
break;
case "ellipse":
{
const rx = parseFloat(cloze.dataset.rx!);
const ry = parseFloat(cloze.dataset.ry!);
const newLeft = posLeft + rx;
const newTop = posTop + ry;
ctx.beginPath();
ctx.strokeStyle = border.color;
ctx.lineWidth = border.width;
ctx.ellipse(newLeft, newTop, rx, ry, 0, 0, Math.PI * 2, false);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
break;
case "polygon":
{
const points = JSON.parse(cloze.dataset.points!);
ctx.beginPath();
ctx.strokeStyle = border.color;
ctx.lineWidth = border.width;
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i][0], points[i][1]);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
break;
default:
break;
ctx.strokeStyle = border.color;
ctx.lineWidth = border.width;
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();
}
}
// following function copy+pasted from mask-editor.ts,
// so update both, if it changes
function limitSize(size: { width: number; height: number }): { width: number; height: number; scalar: number } {
const maximumPixels = 1000000;
const { width, height } = size;
function getPolygonOffset(polygon: Polygon): { x: number; y: number } {
const topLeft = topLeftOfPoints(polygon.points);
return { x: polygon.left - topLeft.x, y: polygon.top - topLeft.y };
}
const requiredPixels = width * height;
if (requiredPixels <= maximumPixels) return { width, height, scalar: 1 };
const scalar = Math.sqrt(maximumPixels) / Math.sqrt(requiredPixels);
return {
width: Math.floor(width * scalar),
height: Math.floor(height * scalar),
scalar: scalar,
};
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 };
}
function getShapeProperty(): {

View File

@ -0,0 +1,69 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import { SHAPE_MASK_COLOR } from "../tools/lib";
import type { ConstructorParams, Size } from "../types";
import { floatToDisplay } from "./floats";
import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position";
export type ShapeOrShapes = Shape | Shape[];
/** Defines a basic shape that can have its coordinates stored in either
absolute pixels (relative to a containing canvas), or in normalized 0-1
form. Can be converted to a fabric object, or to a format suitable for
storage in a cloze note.
*/
export class Shape {
left: number;
top: number;
fill: string = SHAPE_MASK_COLOR;
/** Whether occlusions from other cloze numbers should be shown on the
* question side.
*/
occludeInactive = false;
constructor(
{ left = 0, top = 0, fill = SHAPE_MASK_COLOR, occludeInactive = false }: ConstructorParams<Shape> = {},
) {
this.left = left;
this.top = top;
this.fill = fill;
this.occludeInactive = occludeInactive;
}
/** Format numbers and remove default values, for easier serialization to
* text.
*/
toDataForCloze(): ShapeDataForCloze {
return {
left: floatToDisplay(this.left),
top: floatToDisplay(this.top),
...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }),
...(this.occludeInactive ? { oi: "1" } : {}),
};
}
toFabric(size: Size): fabric.ForCloze {
this.makeAbsolute(size);
return new fabric.Object(this);
}
makeNormal(size: Size): void {
this.left = xToNormalized(size, this.left);
this.top = yToNormalized(size, this.top);
}
makeAbsolute(size: Size): void {
this.left = xFromNormalized(size, this.left);
this.top = yFromNormalized(size, this.top);
}
}
export interface ShapeDataForCloze {
left: string;
top: string;
fill?: string;
oi?: string;
}

View File

@ -0,0 +1,51 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import type { ConstructorParams, Size } from "../types";
import type { ShapeDataForCloze } from "./base";
import { Shape } from "./base";
import { floatToDisplay } from "./floats";
import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position";
export class Ellipse extends Shape {
rx: number;
ry: number;
constructor({ rx = 0, ry = 0, ...rest }: ConstructorParams<Ellipse> = {}) {
super(rest);
this.rx = rx;
this.ry = ry;
}
toDataForCloze(): EllipseDataForCloze {
return {
...super.toDataForCloze(),
rx: floatToDisplay(this.rx),
ry: floatToDisplay(this.ry),
};
}
toFabric(size: Size): fabric.Ellipse {
this.makeAbsolute(size);
return new fabric.Ellipse(this);
}
makeNormal(size: Size): void {
super.makeNormal(size);
this.rx = xToNormalized(size, this.rx);
this.ry = yToNormalized(size, this.ry);
}
makeAbsolute(size: Size): void {
super.makeAbsolute(size);
this.rx = xFromNormalized(size, this.rx);
this.ry = yFromNormalized(size, this.ry);
}
}
interface EllipseDataForCloze extends ShapeDataForCloze {
rx: string;
ry: string;
}

View File

@ -0,0 +1,10 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/** Convert a float to a string with up to 4 fraction digits,
* which when rounded, reproduces identical pixels to input
* for up to widths/heights of 10kpx.
*/
export function floatToDisplay(number: number): string {
return number.toFixed(4).replace(/^0+|0+$/g, "");
}

View File

@ -0,0 +1,136 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-explicit-any: "off",
*/
import type { Shape, ShapeOrShapes } from "./base";
import { Ellipse } from "./ellipse";
import { Point, Polygon } from "./polygon";
import { Rectangle } from "./rectangle";
/** Given a cloze field with text like the following, extract the shapes from it:
* {{c1::image-occlusion:rect:left=10.0:top=20:width=30:height=10:fill=#ffe34d}}
*/
export function extractShapesFromClozedField(clozeStr: string): ShapeOrShapes[] {
const regex = /{{(.*?)}}/g;
const clozeStrList: string[] = [];
let match: string[] | null;
while ((match = regex.exec(clozeStr)) !== null) {
clozeStrList.push(match[1]);
}
const clozeList = {};
for (const str of clozeStrList) {
const [prefix, value] = str.split("::image-occlusion:");
if (!clozeList[prefix]) {
clozeList[prefix] = [];
}
clozeList[prefix].push(value);
}
const output: ShapeOrShapes[] = [];
for (const index in clozeList) {
if (clozeList[index].length > 1) {
const group: Shape[] = [];
clozeList[index].forEach((cloze) => {
let shape: Shape | null = null;
if ((shape = extractShapeFromClozeText(cloze))) {
group.push(shape);
}
});
output.push(group);
} else {
let shape: Shape | null = null;
if ((shape = extractShapeFromClozeText(clozeList[index][0]))) {
output.push(shape);
}
}
}
return output;
}
function extractShapeFromClozeText(text: string): Shape | null {
const [type, props] = extractTypeAndPropsFromClozeText(text);
if (!type) {
return null;
}
return buildShape(type, props);
}
function extractTypeAndPropsFromClozeText(text: string): [ShapeType | null, Record<string, any>] {
const parts = text.split(":");
const type = parts[0];
if (type !== "rect" && type !== "ellipse" && type !== "polygon") {
return [null, {}];
}
const props = {};
for (let i = 1; i < parts.length; i++) {
const [key, value] = parts[i].split("=");
props[key] = value;
}
return [type, props];
}
/** Locate all cloze divs in the review screen for the given selector, and convert them into BaseShapes.
*/
export function extractShapesFromRenderedClozes(selector: string): Shape[] {
return Array.from(document.querySelectorAll(selector)).flatMap((cloze) => {
if (cloze instanceof HTMLDivElement) {
return extractShapeFromRenderedCloze(cloze) ?? [];
} else {
return [];
}
});
}
function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
const type = cloze.dataset.shape!;
if (type !== "rect" && type !== "ellipse" && type !== "polygon") {
return null;
}
const props = {
occludeInactive: cloze.dataset.occludeinactive === "1",
left: cloze.dataset.left,
top: cloze.dataset.top,
width: cloze.dataset.width,
height: cloze.dataset.height,
rx: cloze.dataset.rx,
ry: cloze.dataset.ry,
points: cloze.dataset.points,
};
return buildShape(type, props);
}
type ShapeType = "rect" | "ellipse" | "polygon";
function buildShape(type: ShapeType, props: Record<string, any>): Shape {
props.left = parseFloat(props.left);
props.top = parseFloat(props.top);
switch (type) {
case "rect": {
return new Rectangle({ ...props, width: parseFloat(props.width), height: parseFloat(props.height) });
}
case "ellipse": {
return new Ellipse({
...props,
rx: parseFloat(props.rx),
ry: parseFloat(props.ry),
});
}
case "polygon": {
if (props.points !== "") {
props.points = props.points.split(" ").map((point) => {
const [x, y] = point.split(",");
return new Point({ x, y });
});
} else {
props.points = [new Point({ x: 0, y: 0 })];
}
return new Polygon(props);
}
}
}

View File

@ -0,0 +1,62 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import type { ConstructorParams, Size } from "../types";
import type { ShapeDataForCloze } from "./base";
import { Shape } from "./base";
import { floatToDisplay } from "./floats";
import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position";
export class Polygon extends Shape {
points: Point[];
constructor({ points = [], ...rest }: ConstructorParams<Polygon> = {}) {
super(rest);
this.points = points;
}
toDataForCloze(): PolygonDataForCloze {
return {
...super.toDataForCloze(),
points: this.points.map(({ x, y }) => `${floatToDisplay(x)},${floatToDisplay(y)}`).join(" "),
};
}
toFabric(size: Size): fabric.Polygon {
this.makeAbsolute(size);
return new fabric.Polygon(this.points, this);
}
makeNormal(size: Size): void {
super.makeNormal(size);
this.points.forEach((p) => {
p.x = xToNormalized(size, p.x);
p.y = yToNormalized(size, p.y);
});
}
makeAbsolute(size: Size): void {
super.makeAbsolute(size);
this.points.forEach((p) => {
p.x = xFromNormalized(size, p.x);
p.y = yFromNormalized(size, p.y);
});
}
}
interface PolygonDataForCloze extends ShapeDataForCloze {
// "x1,y1 x2,y2 ...""
points: string;
}
export class Point {
x = 0;
y = 0;
constructor({ x = 0, y = 0 }: ConstructorParams<Point> = {}) {
this.x = x;
this.y = y;
}
}

View File

@ -0,0 +1,24 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Size } from "../types";
/** Position normalized to 0-1 range, e.g. 150px in a 600x300px canvas is 0.25 */
export function xToNormalized(size: Size, x: number): number {
return x / size.width;
}
/** Position normalized to 0-1 range, e.g. 150px in a 600x300px canvas is 0.5 */
export function yToNormalized(size: Size, y: number): number {
return y / size.height;
}
/** Position in pixels from normalized range, e.g 0.25 in a 600x300px canvas is 150. */
export function xFromNormalized(size: Size, x: number): number {
return Math.round(x * size.width);
}
/** Position in pixels from normalized range, e.g 0.5 in a 600x300px canvas is 150. */
export function yFromNormalized(size: Size, y: number): number {
return Math.round(y * size.height);
}

View File

@ -0,0 +1,51 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import type { ConstructorParams, Size } from "../types";
import type { ShapeDataForCloze } from "./base";
import { Shape } from "./base";
import { floatToDisplay } from "./floats";
import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position";
export class Rectangle extends Shape {
width: number;
height: number;
constructor({ width = 0, height = 0, ...rest }: ConstructorParams<Rectangle> = {}) {
super(rest);
this.width = width;
this.height = height;
}
toDataForCloze(): RectangleDataForCloze {
return {
...super.toDataForCloze(),
width: floatToDisplay(this.width),
height: floatToDisplay(this.height),
};
}
toFabric(size: Size): fabric.Rect {
this.makeAbsolute(size);
return new fabric.Rect(this);
}
makeNormal(size: Size): void {
super.makeNormal(size);
this.width = xToNormalized(size, this.width);
this.height = yToNormalized(size, this.height);
}
makeAbsolute(size: Size): void {
super.makeAbsolute(size);
this.width = xFromNormalized(size, this.width);
this.height = yFromNormalized(size, this.height);
}
}
interface RectangleDataForCloze extends ShapeDataForCloze {
width: string;
height: string;
}

View File

@ -0,0 +1,106 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Canvas, Object as FabricObject } from "fabric";
import { makeMaskTransparent } from "../tools/lib";
import type { Size } from "../types";
import type { Shape, ShapeOrShapes } from "./base";
import { Ellipse } from "./ellipse";
import { Polygon } from "./polygon";
import { Rectangle } from "./rectangle";
export function exportShapesToClozeDeletions(occludeInactive: boolean): { clozes: string; noteCount: number } {
const shapes = baseShapesFromFabric(occludeInactive);
let clozes = "";
shapes.forEach((shapeOrShapes, index) => {
clozes += shapeOrShapesToCloze(shapeOrShapes, index);
});
return { clozes, noteCount: shapes.length };
}
/** Gather all Fabric shapes, and convert them into BaseShapes or
* BaseShape[]s.
*/
function baseShapesFromFabric(occludeInactive: boolean): ShapeOrShapes[] {
const canvas = globalThis.canvas as Canvas;
makeMaskTransparent(canvas, false);
const objects = canvas.getObjects() as FabricObject[];
return objects.map((object) => {
return fabricObjectToBaseShapeOrShapes(canvas, object, occludeInactive);
}).filter((o): o is ShapeOrShapes => o !== null);
}
interface TopAndLeftOffset {
top: number;
left: number;
}
/** Convert a single Fabric object/group to one or more BaseShapes. */
function fabricObjectToBaseShapeOrShapes(
size: Size,
object: FabricObject,
occludeInactive: boolean,
groupOffset: TopAndLeftOffset = { top: 0, left: 0 },
): ShapeOrShapes | null {
let shape: Shape;
switch (object.type) {
case "rect":
shape = new Rectangle(object);
break;
case "ellipse":
shape = new Ellipse(object);
break;
case "polygon":
shape = new Polygon(object);
break;
case "group":
// Positions inside a group are relative to the group, so we
// need to pass in an offset. We do not support nested groups.
groupOffset = {
left: object.left + object.width / 2,
top: object.top + object.height / 2,
};
return object._objects.map((obj) => {
return fabricObjectToBaseShapeOrShapes(size, obj, occludeInactive, groupOffset);
});
default:
return null;
}
shape.occludeInactive = occludeInactive;
shape.left += groupOffset.left;
shape.top += groupOffset.top;
shape.makeNormal(size);
return shape;
}
/** generate cloze data in form of
{{c1::image-occlusion:rect:top=.1:left=.23:width=.4:height=.5}} */
function shapeOrShapesToCloze(shapeOrShapes: ShapeOrShapes, index: number): string {
let text = "";
function addKeyValue(key: string, value: string) {
text += `:${key}=${value}`;
}
let type: string;
if (Array.isArray(shapeOrShapes)) {
return shapeOrShapes.map((shape) => shapeOrShapesToCloze(shape, index)).join("");
} else if (shapeOrShapes instanceof Rectangle) {
type = "rect";
} else if (shapeOrShapes instanceof Ellipse) {
type = "ellipse";
} else if (shapeOrShapes instanceof Polygon) {
type = "polygon";
} else {
throw new Error("Unknown shape type");
}
for (const [key, value] of Object.entries(shapeOrShapes.toDataForCloze())) {
addKeyValue(key, value);
}
text = `{{c${index + 1}::image-occlusion:${type}${text}}}<br>`;
return text;
}

View File

@ -0,0 +1,30 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import { extractShapesFromClozedField } from "image-occlusion/shapes/from-cloze";
import type { Size } from "../types";
import { addBorder, disableRotation } from "./lib";
export const addShapesToCanvasFromCloze = (canvas: fabric.Canvas, clozeStr: string): void => {
const size: Size = canvas;
for (const shapeOrShapes of extractShapesFromClozedField(clozeStr)) {
if (Array.isArray(shapeOrShapes)) {
const group = new fabric.Group();
shapeOrShapes.map((shape) => {
const fabricShape = shape.toFabric(size);
addBorder(fabricShape);
group.addWithUpdate(fabricShape);
disableRotation(group);
});
canvas.add(group);
} else {
const shape = shapeOrShapes.toFabric(size);
addBorder(shape);
disableRotation(shape);
canvas.add(shape);
}
}
canvas.requestRenderAll();
};

View File

@ -7,8 +7,8 @@ import { get } from "svelte/store";
import { zoomResetValue } from "../store";
export const shapeMaskColor = "#ffeba2";
export const borderColor = "#212121";
export const SHAPE_MASK_COLOR = "#ffeba2";
export const BORDER_COLOR = "#212121";
let _clipboard;
@ -206,3 +206,13 @@ const setShapePosition = (canvas: fabric.Canvas, object: fabric.Object): void =>
}
object.setCoords();
};
export function disableRotation(obj: fabric.Object): void {
obj.setControlsVisibility({
mtr: false,
});
}
export function addBorder(obj: fabric.Object): void {
obj.stroke = BORDER_COLOR;
}

View File

@ -1,131 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import { shapeMaskColor } from "./lib";
export const generateShapeFromCloze = (canvas: fabric.Canvas, clozeStr: string): void => {
// generate shapes from clozeStr similar to following
// {{c1::image-occlusion:rect:left=10.0:top=20:width=30:height=10:fill=#ffe34d}}
const regex = /{{(.*?)}}/g;
const clozeStrList: string[] = [];
let match: string[] | null;
while ((match = regex.exec(clozeStr)) !== null) {
clozeStrList.push(match[1]);
}
const clozeList = {};
for (const str of clozeStrList) {
const [prefix, value] = str.split("::image-occlusion:");
if (!clozeList[prefix]) {
clozeList[prefix] = [];
}
clozeList[prefix].push(value);
}
for (const index in clozeList) {
let shape: fabric.Group | fabric.Rect | fabric.Ellipse | fabric.Polygon;
if (clozeList[index].length > 1) {
const group = new fabric.Group();
clozeList[index].forEach((shape) => {
const parts = shape.split(":");
const objectType = parts[0];
const objectProperties = {
angle: "0",
left: "0",
top: "0",
width: "0",
height: "0",
fill: shapeMaskColor,
rx: "0",
ry: "0",
points: "",
};
for (let i = 1; i < parts.length; i++) {
const keyValue = parts[i].split("=");
const key = keyValue[0];
const value = keyValue[1];
objectProperties[key] = value;
}
shape = drawShapes(objectType, objectProperties);
group.addWithUpdate(shape);
});
canvas.add(group);
} else {
const cloze = clozeList[index][0];
const parts = cloze.split(":");
const objectType = parts[0];
const objectProperties = {
angle: "0",
left: "0",
top: "0",
width: "0",
height: "0",
fill: shapeMaskColor,
rx: "0",
ry: "0",
points: "",
};
for (let i = 1; i < parts.length; i++) {
const keyValue = parts[i].split("=");
const key = keyValue[0];
const value = keyValue[1];
objectProperties[key] = value;
}
shape = drawShapes(objectType, objectProperties);
canvas.add(shape);
}
}
canvas.requestRenderAll();
};
const drawShapes = (objectType, objectProperties) => {
switch (objectType) {
case "rect": {
const rect = new fabric.Rect({
left: parseFloat(objectProperties.left),
top: parseFloat(objectProperties.top),
width: parseFloat(objectProperties.width),
height: parseFloat(objectProperties.height),
fill: objectProperties.fill,
selectable: false,
});
return rect;
}
case "ellipse": {
const ellipse = new fabric.Ellipse({
left: parseFloat(objectProperties.left),
top: parseFloat(objectProperties.top),
rx: parseFloat(objectProperties.rx),
ry: parseFloat(objectProperties.ry),
fill: objectProperties.fill,
selectable: false,
});
return ellipse;
}
case "polygon": {
const points = objectProperties.points.split(" ");
const polygon = new fabric.Polygon(points, {
left: parseFloat(objectProperties.left),
top: parseFloat(objectProperties.top),
fill: objectProperties.fill,
selectable: false,
});
return polygon;
}
default:
break;
}
};

View File

@ -3,7 +3,7 @@
import { fabric } from "fabric";
import { borderColor, shapeMaskColor, stopDraw } from "./lib";
import { BORDER_COLOR, disableRotation, SHAPE_MASK_COLOR, stopDraw } from "./lib";
import { objectAdded } from "./tool-undo-redo";
const addedEllipseIds: string[] = [];
@ -32,14 +32,15 @@ export const drawEllipse = (canvas: fabric.Canvas): void => {
originY: "top",
rx: pointer.x - origX,
ry: pointer.y - origY,
fill: shapeMaskColor,
fill: SHAPE_MASK_COLOR,
transparentCorners: false,
selectable: true,
stroke: borderColor,
stroke: BORDER_COLOR,
strokeWidth: 1,
strokeUniform: true,
noScaleCache: false,
});
disableRotation(ellipse);
canvas.add(ellipse);
});

View File

@ -4,7 +4,7 @@
import { fabric } from "fabric";
import type { PanZoom } from "panzoom";
import { borderColor, shapeMaskColor } from "./lib";
import { BORDER_COLOR, disableRotation, SHAPE_MASK_COLOR } from "./lib";
import { objectAdded, saveCanvasState } from "./tool-undo-redo";
let activeLine;
@ -179,14 +179,15 @@ const generatePolygon = (canvas: fabric.Canvas, pointsList): void => {
const polygon = new fabric.Polygon(points, {
id: "polygon-" + new Date().getTime(),
fill: shapeMaskColor,
fill: SHAPE_MASK_COLOR,
objectCaching: false,
stroke: borderColor,
stroke: BORDER_COLOR,
strokeWidth: 1,
strokeUniform: true,
noScaleCache: false,
});
if (polygon.width > 5 && polygon.height > 5) {
disableRotation(polygon);
canvas.add(polygon);
// view undo redo tools
objectAdded(canvas, addedPolygonIds, polygon.id);
@ -212,9 +213,9 @@ const modifiedPolygon = (canvas: fabric.Canvas, polygon: fabric.Polygon): void =
const polygon1 = new fabric.Polygon(transformedPoints, {
id: polygon.id,
fill: shapeMaskColor,
fill: SHAPE_MASK_COLOR,
objectCaching: false,
stroke: borderColor,
stroke: BORDER_COLOR,
strokeWidth: 1,
strokeUniform: true,
noScaleCache: false,

View File

@ -3,7 +3,7 @@
import { fabric } from "fabric";
import { borderColor, shapeMaskColor, stopDraw } from "./lib";
import { BORDER_COLOR, disableRotation, SHAPE_MASK_COLOR, stopDraw } from "./lib";
import { objectAdded } from "./tool-undo-redo";
const addedRectangleIds: string[] = [];
@ -33,14 +33,15 @@ export const drawRectangle = (canvas: fabric.Canvas): void => {
width: pointer.x - origX,
height: pointer.y - origY,
angle: 0,
fill: shapeMaskColor,
fill: SHAPE_MASK_COLOR,
transparentCorners: false,
selectable: true,
stroke: borderColor,
stroke: BORDER_COLOR,
strokeWidth: 1,
strokeUniform: true,
noScaleCache: false,
});
disableRotation(rect);
canvas.add(rect);
});

View File

@ -3,6 +3,7 @@
"include": [
"*",
"tools/*",
"shapes/*",
"notes-toolbar"
],
"references": [

View File

@ -0,0 +1,11 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export interface Size {
width: number;
height: number;
}
export type ConstructorParams<T> = {
[P in keyof T]?: T[P];
};

View File

@ -9,8 +9,8 @@ import "css-browser-selector/css_browser_selector.min";
export { default as $, default as jQuery } from "jquery/dist/jquery";
import { setupImageCloze } from "../image-occlusion/review";
import { mutateNextCardStates } from "./answering";
import { setupImageCloze } from "./image_occlusion";
globalThis.anki = globalThis.anki || {};
globalThis.anki.mutateNextCardStates = mutateNextCardStates;

View File

@ -5,11 +5,12 @@
@typescript-eslint/no-explicit-any: "off",
*/
// A standalone bundle that adds mutateNextCardStates to the anki namespace.
// When all clients are using reviewer.js directly, we can get rid of this.
// A standalone bundle that adds mutateNextCardStates and setupImageCloze
// to the anki namespace. When all clients are using reviewer.js directly, we
// can get rid of this.
import { setupImageCloze } from "../image-occlusion/review";
import { mutateNextCardStates } from "./answering";
import { setupImageCloze } from "./image_occlusion";
globalThis.anki = globalThis.anki || {};
globalThis.anki.mutateNextCardStates = mutateNextCardStates;