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:
parent
c87f62487b
commit
7f6c410ca5
@ -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 {
|
||||
|
@ -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" "#,
|
||||
);
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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" />
|
||||
|
@ -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}
|
||||
|
70
ts/image-occlusion/add-or-update-note.ts
Normal file
70
ts/image-occlusion/add-or-update-note.ts
Normal 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 });
|
||||
}
|
||||
};
|
48
ts/image-occlusion/canvas-scale.ts
Normal file
48
ts/image-occlusion/canvas-scale.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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 });
|
||||
}
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@ -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(): {
|
69
ts/image-occlusion/shapes/base.ts
Normal file
69
ts/image-occlusion/shapes/base.ts
Normal 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;
|
||||
}
|
51
ts/image-occlusion/shapes/ellipse.ts
Normal file
51
ts/image-occlusion/shapes/ellipse.ts
Normal 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;
|
||||
}
|
10
ts/image-occlusion/shapes/floats.ts
Normal file
10
ts/image-occlusion/shapes/floats.ts
Normal 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, "");
|
||||
}
|
136
ts/image-occlusion/shapes/from-cloze.ts
Normal file
136
ts/image-occlusion/shapes/from-cloze.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
62
ts/image-occlusion/shapes/polygon.ts
Normal file
62
ts/image-occlusion/shapes/polygon.ts
Normal 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;
|
||||
}
|
||||
}
|
24
ts/image-occlusion/shapes/position.ts
Normal file
24
ts/image-occlusion/shapes/position.ts
Normal 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);
|
||||
}
|
51
ts/image-occlusion/shapes/rectangle.ts
Normal file
51
ts/image-occlusion/shapes/rectangle.ts
Normal 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;
|
||||
}
|
106
ts/image-occlusion/shapes/to-cloze.ts
Normal file
106
ts/image-occlusion/shapes/to-cloze.ts
Normal 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;
|
||||
}
|
30
ts/image-occlusion/tools/add-from-cloze.ts
Normal file
30
ts/image-occlusion/tools/add-from-cloze.ts
Normal 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();
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
"include": [
|
||||
"*",
|
||||
"tools/*",
|
||||
"shapes/*",
|
||||
"notes-toolbar"
|
||||
],
|
||||
"references": [
|
||||
|
11
ts/image-occlusion/types.ts
Normal file
11
ts/image-occlusion/types.ts
Normal 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];
|
||||
};
|
@ -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;
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user