diff --git a/proto/anki/image_occlusion.proto b/proto/anki/image_occlusion.proto
index cd600c7c0..82f7d38ce 100644
--- a/proto/anki/image_occlusion.proto
+++ b/proto/anki/image_occlusion.proto
@@ -54,9 +54,19 @@ message GetImageOcclusionNoteRequest {
}
message GetImageOcclusionNoteResponse {
+ message ImageOcclusionProperty {
+ string name = 1;
+ string value = 2;
+ }
+
+ message ImageOcclusion {
+ string shape = 1;
+ repeated ImageOcclusionProperty properties = 2;
+ }
+
message ImageClozeNote {
bytes image_data = 1;
- string occlusions = 2;
+ repeated ImageOcclusion occlusions = 2;
string header = 3;
string back_extra = 4;
repeated string tags = 5;
diff --git a/rslib/src/cloze.rs b/rslib/src/cloze.rs
index e92b19ff4..7c3b8d9bc 100644
--- a/rslib/src/cloze.rs
+++ b/rslib/src/cloze.rs
@@ -5,6 +5,7 @@ use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt::Write;
+use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusion;
use htmlescape::encode_attribute;
use lazy_static::lazy_static;
use nom::branch::alt;
@@ -16,6 +17,7 @@ use regex::Captures;
use regex::Regex;
use crate::image_occlusion::imageocclusion::get_image_cloze_data;
+use crate::image_occlusion::imageocclusion::parse_image_cloze;
use crate::latex::contains_latex;
use crate::template::RenderContext;
use crate::text::strip_html_preserving_entities;
@@ -229,6 +231,7 @@ fn reveal_cloze(
image_occlusion_text,
question,
active,
+ cloze.ordinal,
));
return;
}
@@ -295,8 +298,8 @@ fn reveal_cloze(
}
}
-fn render_image_occlusion(text: &str, question_side: bool, active: bool) -> String {
- if question_side && active {
+fn render_image_occlusion(text: &str, question_side: bool, active: bool, ordinal: u16) -> String {
+ if (question_side && active) || ordinal == 0 {
format!(
r#"
"#,
&get_image_cloze_data(text)
@@ -311,6 +314,18 @@ fn render_image_occlusion(text: &str, question_side: bool, active: bool) -> Stri
}
}
+pub fn parse_image_occlusions(text: &str) -> Vec {
+ parse_text_with_clozes(text)
+ .iter()
+ .filter_map(|node| match node {
+ TextOrCloze::Cloze(cloze) if cloze.image_occlusion().is_some() => {
+ parse_image_cloze(cloze.image_occlusion().unwrap())
+ }
+ _ => None,
+ })
+ .collect()
+}
+
pub fn reveal_cloze_text(text: &str, cloze_ord: u16, question: bool) -> Cow {
let mut buf = String::new();
let mut active_cloze_found_in_text = false;
@@ -377,7 +392,7 @@ pub fn expand_clozes_to_reveal_latex(text: &str) -> String {
pub(crate) fn contains_cloze(text: &str) -> bool {
parse_text_with_clozes(text)
.iter()
- .any(|node| matches!(node, TextOrCloze::Cloze(_)))
+ .any(|node| matches!(node, TextOrCloze::Cloze(e) if e.ordinal != 0))
}
pub fn cloze_numbers_in_string(html: &str) -> HashSet {
@@ -389,8 +404,10 @@ pub fn cloze_numbers_in_string(html: &str) -> HashSet {
fn add_cloze_numbers_in_text_with_clozes(nodes: &[TextOrCloze], set: &mut HashSet) {
for node in nodes {
if let TextOrCloze::Cloze(cloze) = node {
- set.insert(cloze.ordinal);
- add_cloze_numbers_in_text_with_clozes(&cloze.nodes, set);
+ if !(cloze.image_occlusion().is_some() && cloze.ordinal == 0) {
+ set.insert(cloze.ordinal);
+ add_cloze_numbers_in_text_with_clozes(&cloze.nodes, set);
+ }
}
}
}
diff --git a/rslib/src/image_occlusion/imagedata.rs b/rslib/src/image_occlusion/imagedata.rs
index 68ad28089..3ec3a49ee 100644
--- a/rslib/src/image_occlusion/imagedata.rs
+++ b/rslib/src/image_occlusion/imagedata.rs
@@ -15,6 +15,7 @@ use anki_proto::image_occlusion::ImageOcclusionFieldIndexes;
use anki_proto::notetypes::ImageOcclusionField;
use regex::Regex;
+use crate::cloze::parse_image_occlusions;
use crate::media::MediaManager;
use crate::prelude::*;
@@ -92,7 +93,7 @@ impl Collection {
.or_not_found(note.notetype_id)?;
let idxs = nt.get_io_field_indexes()?;
- cloze_note.occlusions = fields[idxs.occlusions as usize].clone();
+ cloze_note.occlusions = parse_image_occlusions(fields[idxs.occlusions as usize].as_str());
cloze_note.header = fields[idxs.header as usize].clone();
cloze_note.back_extra = fields[idxs.back_extra as usize].clone();
cloze_note.image_data = "".into();
diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs
index 4fd27e585..7fffd73b6 100644
--- a/rslib/src/image_occlusion/imageocclusion.rs
+++ b/rslib/src/image_occlusion/imageocclusion.rs
@@ -3,6 +3,48 @@
use std::fmt::Write;
+use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusion;
+use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionProperty;
+use htmlescape::encode_attribute;
+use nom::bytes::complete::escaped;
+use nom::bytes::complete::is_not;
+use nom::bytes::complete::tag;
+use nom::character::complete::char;
+use nom::error::ErrorKind;
+use nom::sequence::preceded;
+use nom::sequence::separated_pair;
+
+fn unescape(text: &str) -> String {
+ text.replace("\\:", ":")
+}
+
+pub fn parse_image_cloze(text: &str) -> Option {
+ if let Some((shape, _)) = text.split_once(':') {
+ let mut properties = vec![];
+ let mut remaining = &text[shape.len()..];
+ while let Ok((rem, (name, value))) = separated_pair::<_, _, _, _, (_, ErrorKind), _, _, _>(
+ preceded(tag(":"), is_not("=")),
+ tag("="),
+ escaped(is_not("\\:"), '\\', char(':')),
+ )(remaining)
+ {
+ remaining = rem;
+ let value = unescape(value);
+ properties.push(ImageOcclusionProperty {
+ name: name.to_string(),
+ value,
+ })
+ }
+
+ return Some(ImageOcclusion {
+ shape: shape.to_string(),
+ properties,
+ });
+ }
+
+ None
+}
+
// convert text like
// rect:left=.2325:top=.3261:width=.202:height=.0975
// to something like
@@ -10,72 +52,83 @@ use std::fmt::Write;
// 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();
- if parts.len() >= 2 {
- if !parts[0].is_empty()
- && (parts[0] == "rect" || parts[0] == "ellipse" || parts[0] == "polygon")
+ if let Some(occlusion) = parse_image_cloze(text) {
+ if !occlusion.shape.is_empty()
+ && matches!(
+ occlusion.shape.as_str(),
+ "rect" | "ellipse" | "polygon" | "text"
+ )
{
- result.push_str(&format!("data-shape=\"{}\" ", parts[0]));
+ result.push_str(&format!("data-shape=\"{}\" ", occlusion.shape));
}
-
- for part in parts[1..].iter() {
- let values: Vec<&str> = part.split('=').collect();
- if values.len() >= 2 {
- match values[0] {
- "left" => {
- if !values[1].is_empty() {
- result.push_str(&format!("data-left=\"{}\" ", values[1]));
- }
+ for property in occlusion.properties {
+ match property.name.as_str() {
+ "left" => {
+ if !property.value.is_empty() {
+ result.push_str(&format!("data-left=\"{}\" ", property.value));
}
- "top" => {
- if !values[1].is_empty() {
- result.push_str(&format!("data-top=\"{}\" ", values[1]));
- }
- }
- "width" => {
- if !is_empty_or_zero(values[1]) {
- result.push_str(&format!("data-width=\"{}\" ", values[1]));
- }
- }
- "height" => {
- if !is_empty_or_zero(values[1]) {
- result.push_str(&format!("data-height=\"{}\" ", values[1]));
- }
- }
- "rx" => {
- if !is_empty_or_zero(values[1]) {
- result.push_str(&format!("data-rx=\"{}\" ", values[1]));
- }
- }
- "ry" => {
- if !is_empty_or_zero(values[1]) {
- result.push_str(&format!("data-ry=\"{}\" ", values[1]));
- }
- }
- "points" => {
- if !values[1].is_empty() {
- 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();
- }
- // remove the trailing space
- point_str.pop();
- if !point_str.is_empty() {
- result.push_str(&format!("data-points=\"{point_str}\" "));
- }
- }
- }
- "oi" => {
- if !values[1].is_empty() {
- result.push_str(&format!("data-occludeInactive=\"{}\" ", values[1]));
- }
- }
- _ => {}
}
+ "top" => {
+ if !property.value.is_empty() {
+ result.push_str(&format!("data-top=\"{}\" ", property.value));
+ }
+ }
+ "width" => {
+ if !is_empty_or_zero(&property.value) {
+ result.push_str(&format!("data-width=\"{}\" ", property.value));
+ }
+ }
+ "height" => {
+ if !is_empty_or_zero(&property.value) {
+ result.push_str(&format!("data-height=\"{}\" ", property.value));
+ }
+ }
+ "rx" => {
+ if !is_empty_or_zero(&property.value) {
+ result.push_str(&format!("data-rx=\"{}\" ", property.value));
+ }
+ }
+ "ry" => {
+ if !is_empty_or_zero(&property.value) {
+ result.push_str(&format!("data-ry=\"{}\" ", property.value));
+ }
+ }
+ "points" => {
+ if !property.value.is_empty() {
+ let mut point_str = String::new();
+ for point_pair in property.value.split(' ') {
+ let Some((x, y)) = point_pair.split_once(',') else {
+ continue;
+ };
+ write!(&mut point_str, "{},{} ", x, y).unwrap();
+ }
+ // remove the trailing space
+ point_str.pop();
+ if !point_str.is_empty() {
+ result.push_str(&format!("data-points=\"{point_str}\" "));
+ }
+ }
+ }
+ "oi" => {
+ if !property.value.is_empty() {
+ result.push_str(&format!("data-occludeInactive=\"{}\" ", property.value));
+ }
+ }
+ "text" => {
+ if !property.value.is_empty() {
+ result.push_str(&format!(
+ "data-text=\"{}\" ",
+ encode_attribute(&property.value)
+ ));
+ }
+ }
+ "scale" => {
+ if !is_empty_or_zero(&property.value) {
+ result.push_str(&format!("data-scale=\"{}\" ", property.value));
+ }
+ }
+ _ => {}
}
}
}
@@ -107,4 +160,8 @@ fn test_get_image_cloze_data() {
get_image_cloze_data("polygon:points=0,0 10,10 20,0"),
r#"data-shape="polygon" data-points="0,0 10,10 20,0" "#,
);
+ assert_eq!(
+ get_image_cloze_data("text:text=foo\\:bar:left=10"),
+ r#"data-shape="text" data-text="foo:bar" data-left="10" "#,
+ );
}
diff --git a/ts/image-occlusion/Toolbar.svelte b/ts/image-occlusion/Toolbar.svelte
index daec89140..78aa93d09 100644
--- a/ts/image-occlusion/Toolbar.svelte
+++ b/ts/image-occlusion/Toolbar.svelte
@@ -11,7 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { mdiEye, mdiFormatAlignCenter, mdiSquare, mdiViewDashboard } from "./icons";
import { hideAllGuessOne } from "./store";
- import { drawEllipse, drawPolygon, drawRectangle } from "./tools/index";
+ import { drawEllipse, drawPolygon, drawRectangle, drawText } from "./tools/index";
import { makeMaskTransparent } from "./tools/lib";
import { enableSelectable, stopDraw } from "./tools/lib";
import {
@@ -58,6 +58,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
case "draw-polygon":
drawPolygon(canvas, instance);
break;
+ case "draw-text":
+ drawText(canvas);
+ break;
default:
break;
}
diff --git a/ts/image-occlusion/icons.ts b/ts/image-occlusion/icons.ts
index f9bbc917e..e004d9f1e 100644
--- a/ts/image-occlusion/icons.ts
+++ b/ts/image-occlusion/icons.ts
@@ -30,6 +30,7 @@ export { default as mdiRectangleOutline } from "@mdi/svg/svg/rectangle-outline.s
export { default as mdiRedo } from "@mdi/svg/svg/redo.svg";
export { default as mdiRefresh } from "@mdi/svg/svg/refresh.svg";
export { default as mdiSquare } from "@mdi/svg/svg/square.svg";
+export { default as mdiTextBox } from "@mdi/svg/svg/text-box.svg";
export { default as mdiUndo } from "@mdi/svg/svg/undo.svg";
export { default as mdiUnfoldMoreHorizontal } from "@mdi/svg/svg/unfold-more-horizontal.svg";
export { default as mdiUngroup } from "@mdi/svg/svg/ungroup.svg";
diff --git a/ts/image-occlusion/mask-editor.ts b/ts/image-occlusion/mask-editor.ts
index b808ba0dd..54dabee7e 100644
--- a/ts/image-occlusion/mask-editor.ts
+++ b/ts/image-occlusion/mask-editor.ts
@@ -90,7 +90,7 @@ function initCanvas(onChange: () => void): fabric.Canvas {
tagsWritable.set([]);
globalThis.canvas = canvas;
undoStack.setCanvas(canvas);
- // enables uniform scaling by default without the need for the Shift key
+ // Disable uniform scaling
canvas.uniformScaling = false;
canvas.uniScaleKey = "none";
moveShapeToCanvasBoundaries(canvas);
diff --git a/ts/image-occlusion/review.ts b/ts/image-occlusion/review.ts
index 580d46fe3..ec96c6315 100644
--- a/ts/image-occlusion/review.ts
+++ b/ts/image-occlusion/review.ts
@@ -9,6 +9,8 @@ import { Ellipse } from "./shapes/ellipse";
import { extractShapesFromRenderedClozes } from "./shapes/from-cloze";
import { Polygon } from "./shapes/polygon";
import { Rectangle } from "./shapes/rectangle";
+import { Text } from "./shapes/text";
+import { TEXT_FONT_FAMILY, TEXT_PADDING } from "./tools/lib";
import type { Size } from "./types";
export function setupImageCloze(): void {
@@ -19,14 +21,20 @@ export function setupImageCloze(): void {
}
function setupImageClozeInner(): void {
- const canvas = document.querySelector("#image-occlusion-canvas") as HTMLCanvasElement | null;
+ const canvas = document.querySelector(
+ "#image-occlusion-canvas",
+ ) as HTMLCanvasElement | null;
if (canvas == null) {
return;
}
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
- const container = document.getElementById("image-occlusion-container") as HTMLDivElement;
- const image = document.querySelector("#image-occlusion-container img") as HTMLImageElement;
+ const container = document.getElementById(
+ "image-occlusion-container",
+ ) as HTMLDivElement;
+ const image = document.querySelector(
+ "#image-occlusion-container img",
+ ) as HTMLImageElement;
if (image == null) {
container.innerText = tr.notetypeErrorNoImageToShow();
return;
@@ -51,17 +59,18 @@ function setupImageClozeInner(): void {
drawShapes(canvas, ctx);
}
-function drawShapes(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
- const shapeProperty = getShapeProperty();
+function drawShapes(
+ canvas: HTMLCanvasElement,
+ ctx: CanvasRenderingContext2D,
+): void {
+ const properties = getShapeProperties();
const size = canvas;
- for (const active of extractShapesFromRenderedClozes(".cloze")) {
- const fill = shapeProperty.activeShapeColor;
- drawShape(ctx, size, active, fill, shapeProperty.activeBorder);
+ for (const shape of extractShapesFromRenderedClozes(".cloze")) {
+ drawShape(ctx, size, shape, properties, true);
}
- for (const inactive of extractShapesFromRenderedClozes(".cloze-inactive")) {
- const fill = shapeProperty.inActiveShapeColor;
- if (inactive.occludeInactive) {
- drawShape(ctx, size, inactive, fill, shapeProperty.inActiveBorder);
+ for (const shape of extractShapesFromRenderedClozes(".cloze-inactive")) {
+ if (shape.occludeInactive) {
+ drawShape(ctx, size, shape, properties, false);
}
}
}
@@ -70,10 +79,21 @@ function drawShape(
ctx: CanvasRenderingContext2D,
size: Size,
shape: Shape,
- color: string,
- border: { width: number; color: string },
+ properties: ShapeProperties,
+ active: boolean,
): void {
shape.makeAbsolute(size);
+
+ const { color, border } = active
+ ? {
+ color: properties.activeShapeColor,
+ border: properties.activeBorder,
+ }
+ : {
+ color: properties.inActiveShapeColor,
+ border: properties.inActiveBorder,
+ };
+
ctx.fillStyle = color;
ctx.strokeStyle = border.color;
ctx.lineWidth = border.width;
@@ -84,7 +104,16 @@ function drawShape(
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.ellipse(
+ adjustedLeft,
+ adjustedTop,
+ shape.rx,
+ shape.ry,
+ 0,
+ 0,
+ Math.PI * 2,
+ false,
+ );
ctx.closePath();
ctx.fill();
ctx.stroke();
@@ -101,6 +130,26 @@ function drawShape(
ctx.fill();
ctx.stroke();
ctx.restore();
+ } else if (shape instanceof Text) {
+ ctx.save();
+ ctx.font = `40px ${TEXT_FONT_FAMILY}`;
+ ctx.textBaseline = "top";
+ ctx.scale(shape.scaleX, shape.scaleY);
+ const textMetrics = ctx.measureText(shape.text);
+ ctx.fillStyle = properties.inActiveShapeColor;
+ ctx.fillRect(
+ shape.left / shape.scaleX,
+ shape.top / shape.scaleY,
+ textMetrics.width + TEXT_PADDING,
+ textMetrics.actualBoundingBoxDescent + TEXT_PADDING,
+ );
+ ctx.fillStyle = "#000";
+ ctx.fillText(
+ shape.text,
+ shape.left / shape.scaleX,
+ shape.top / shape.scaleY,
+ );
+ ctx.restore();
}
}
@@ -109,7 +158,10 @@ function getPolygonOffset(polygon: Polygon): { x: number; y: number } {
return { x: polygon.left - topLeft.x, y: polygon.top - topLeft.y };
}
-function topLeftOfPoints(points: { x: number; y: number }[]): { x: number; y: number } {
+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) {
@@ -123,40 +175,55 @@ function topLeftOfPoints(points: { x: number; y: number }[]): { x: number; y: nu
return { x: left, y: top };
}
-function getShapeProperty(): {
+type ShapeProperties = {
activeShapeColor: string;
inActiveShapeColor: string;
activeBorder: { width: number; color: string };
inActiveBorder: { width: number; color: string };
-} {
+};
+function getShapeProperties(): ShapeProperties {
const canvas = document.getElementById("image-occlusion-canvas");
const computedStyle = window.getComputedStyle(canvas!);
// it may throw error if the css variable is not defined
try {
// shape color
- const activeShapeColor = computedStyle.getPropertyValue("--active-shape-color");
- const inActiveShapeColor = computedStyle.getPropertyValue("--inactive-shape-color");
+ const activeShapeColor = computedStyle.getPropertyValue(
+ "--active-shape-color",
+ );
+ const inActiveShapeColor = computedStyle.getPropertyValue(
+ "--inactive-shape-color",
+ );
// inactive shape border
- const inActiveShapeBorder = computedStyle.getPropertyValue("--inactive-shape-border");
+ const inActiveShapeBorder = computedStyle.getPropertyValue(
+ "--inactive-shape-border",
+ );
const inActiveBorder = inActiveShapeBorder.split(" ").filter((x) => x);
const inActiveShapeBorderWidth = parseFloat(inActiveBorder[0]);
const inActiveShapeBorderColor = inActiveBorder[1];
// active shape border
- const activeShapeBorder = computedStyle.getPropertyValue("--active-shape-border");
+ const activeShapeBorder = computedStyle.getPropertyValue(
+ "--active-shape-border",
+ );
const activeBorder = activeShapeBorder.split(" ").filter((x) => x);
const activeShapeBorderWidth = parseFloat(activeBorder[0]);
const activeShapeBorderColor = activeBorder[1];
return {
activeShapeColor: activeShapeColor ? activeShapeColor : "#ff8e8e",
- inActiveShapeColor: inActiveShapeColor ? inActiveShapeColor : "#ffeba2",
+ inActiveShapeColor: inActiveShapeColor
+ ? inActiveShapeColor
+ : "#ffeba2",
activeBorder: {
width: activeShapeBorderWidth ? activeShapeBorderWidth : 1,
- color: activeShapeBorderColor ? activeShapeBorderColor : "#212121",
+ color: activeShapeBorderColor
+ ? activeShapeBorderColor
+ : "#212121",
},
inActiveBorder: {
width: inActiveShapeBorderWidth ? inActiveShapeBorderWidth : 1,
- color: inActiveShapeBorderColor ? inActiveShapeBorderColor : "#212121",
+ color: inActiveShapeBorderColor
+ ? inActiveShapeBorderColor
+ : "#212121",
},
};
} catch {
@@ -177,7 +244,9 @@ function getShapeProperty(): {
}
const toggleMasks = (): void => {
- const canvas = document.getElementById("image-occlusion-canvas") as HTMLCanvasElement;
+ const canvas = document.getElementById(
+ "image-occlusion-canvas",
+ ) as HTMLCanvasElement;
const display = canvas.style.display;
if (display === "none") {
canvas.style.display = "unset";
diff --git a/ts/image-occlusion/shapes/from-cloze.ts b/ts/image-occlusion/shapes/from-cloze.ts
index 5445ef763..f018dc771 100644
--- a/ts/image-occlusion/shapes/from-cloze.ts
+++ b/ts/image-occlusion/shapes/from-cloze.ts
@@ -5,76 +5,28 @@
@typescript-eslint/no-explicit-any: "off",
*/
+import type { GetImageOcclusionNoteResponse_ImageOcclusion } from "@tslib/anki/image_occlusion_pb";
+
import type { Shape, ShapeOrShapes } from "./base";
import { Ellipse } from "./ellipse";
import { Point, Polygon } from "./polygon";
import { Rectangle } from "./rectangle";
+import { Text } from "./text";
-/** 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);
- }
-
+export function extractShapesFromClozedField(
+ occlusions: GetImageOcclusionNoteResponse_ImageOcclusion[],
+): ShapeOrShapes[] {
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);
- }
+ for (const occlusion of occlusions) {
+ if (isValidType(occlusion.shape)) {
+ const props = Object.fromEntries(occlusion.properties.map(prop => [prop.name, prop.value]));
+ output.push(buildShape(occlusion.shape, props));
}
}
+
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] {
- 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[] {
@@ -89,7 +41,12 @@ export function extractShapesFromRenderedClozes(selector: string): Shape[] {
function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
const type = cloze.dataset.shape!;
- if (type !== "rect" && type !== "ellipse" && type !== "polygon") {
+ if (
+ type !== "rect"
+ && type !== "ellipse"
+ && type !== "polygon"
+ && type !== "text"
+ ) {
return null;
}
const props = {
@@ -101,18 +58,32 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
rx: cloze.dataset.rx,
ry: cloze.dataset.ry,
points: cloze.dataset.points,
+ text: cloze.dataset.text,
+ scale: cloze.dataset.scale,
};
return buildShape(type, props);
}
-type ShapeType = "rect" | "ellipse" | "polygon";
+type ShapeType = "rect" | "ellipse" | "polygon" | "text";
+
+function isValidType(type: string): type is ShapeType {
+ return ["rect", "ellipse", "polygon", "text"].includes(type);
+}
function buildShape(type: ShapeType, props: Record): Shape {
- props.left = parseFloat(Number.isNaN(Number(props.left)) ? ".0000" : props.left);
- props.top = parseFloat(Number.isNaN(Number(props.top)) ? ".0000" : props.top);
+ props.left = parseFloat(
+ Number.isNaN(Number(props.left)) ? ".0000" : props.left,
+ );
+ props.top = parseFloat(
+ Number.isNaN(Number(props.top)) ? ".0000" : props.top,
+ );
switch (type) {
case "rect": {
- return new Rectangle({ ...props, width: parseFloat(props.width), height: parseFloat(props.height) });
+ return new Rectangle({
+ ...props,
+ width: parseFloat(props.width),
+ height: parseFloat(props.height),
+ });
}
case "ellipse": {
return new Ellipse({
@@ -132,5 +103,12 @@ function buildShape(type: ShapeType, props: Record): Shape {
}
return new Polygon(props);
}
+ case "text": {
+ return new Text({
+ ...props,
+ scaleX: parseFloat(props.scale),
+ scaleY: parseFloat(props.scale),
+ });
+ }
}
}
diff --git a/ts/image-occlusion/shapes/text.ts b/ts/image-occlusion/shapes/text.ts
new file mode 100644
index 000000000..4315e456e
--- /dev/null
+++ b/ts/image-occlusion/shapes/text.ts
@@ -0,0 +1,65 @@
+// 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, TEXT_FONT_FAMILY, TEXT_PADDING } from "../tools/lib";
+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 Text extends Shape {
+ text: string;
+ scaleX: number;
+ scaleY: number;
+
+ constructor({
+ text = "",
+ scaleX = 1,
+ scaleY = 1,
+ ...rest
+ }: ConstructorParams = {}) {
+ super(rest);
+ this.text = text;
+ this.scaleX = scaleX;
+ this.scaleY = scaleY;
+ }
+
+ toDataForCloze(): TextDataForCloze {
+ return {
+ ...super.toDataForCloze(),
+ text: this.text,
+ // scaleX and scaleY are guaranteed to be equal since we lock the aspect ratio
+ scale: floatToDisplay(this.scaleX),
+ };
+ }
+
+ toFabric(size: Size): fabric.IText {
+ this.makeAbsolute(size);
+ return new fabric.IText(this.text, {
+ ...this,
+ fontFamily: TEXT_FONT_FAMILY,
+ backgroundColor: SHAPE_MASK_COLOR,
+ padding: TEXT_PADDING,
+ });
+ }
+
+ makeNormal(size: Size): void {
+ super.makeNormal(size);
+ this.scaleX = xToNormalized(size, this.scaleX);
+ this.scaleY = yToNormalized(size, this.scaleY);
+ }
+
+ makeAbsolute(size: Size): void {
+ super.makeAbsolute(size);
+ this.scaleX = xFromNormalized(size, this.scaleX);
+ this.scaleY = yFromNormalized(size, this.scaleY);
+ }
+}
+
+interface TextDataForCloze extends ShapeDataForCloze {
+ text: string;
+ scale: string;
+}
diff --git a/ts/image-occlusion/shapes/to-cloze.ts b/ts/image-occlusion/shapes/to-cloze.ts
index 96f4db274..c439c0887 100644
--- a/ts/image-occlusion/shapes/to-cloze.ts
+++ b/ts/image-occlusion/shapes/to-cloze.ts
@@ -11,6 +11,7 @@ import type { Shape, ShapeOrShapes } from "./base";
import { Ellipse } from "./ellipse";
import { Polygon } from "./polygon";
import { Rectangle } from "./rectangle";
+import { Text } from "./text";
export function exportShapesToClozeDeletions(occludeInactive: boolean): {
clozes: string;
@@ -19,8 +20,12 @@ export function exportShapesToClozeDeletions(occludeInactive: boolean): {
const shapes = baseShapesFromFabric(occludeInactive);
let clozes = "";
- shapes.forEach((shapeOrShapes, index) => {
+ let index = 0;
+ shapes.forEach((shapeOrShapes) => {
clozes += shapeOrShapesToCloze(shapeOrShapes, index);
+ if (!(shapeOrShapes instanceof Text)) {
+ index++;
+ }
});
return { clozes, noteCount: shapes.length };
@@ -67,6 +72,9 @@ function fabricObjectToBaseShapeOrShapes(
case "polygon":
shape = new Polygon(cloned);
break;
+ case "i-text":
+ shape = new Text(cloned);
+ break;
case "group":
return object._objects.map((child) => {
return fabricObjectToBaseShapeOrShapes(
@@ -101,9 +109,7 @@ function shapeOrShapesToCloze(
): string {
let text = "";
function addKeyValue(key: string, value: string) {
- if (key !== "points" && Number.isNaN(Number(value))) {
- value = ".0000";
- }
+ value = value.replace(":", "\\:");
text += `:${key}=${value}`;
}
@@ -118,6 +124,8 @@ function shapeOrShapesToCloze(
type = "ellipse";
} else if (shapeOrShapes instanceof Polygon) {
type = "polygon";
+ } else if (shapeOrShapes instanceof Text) {
+ type = "text";
} else {
throw new Error("Unknown shape type");
}
@@ -126,6 +134,13 @@ function shapeOrShapesToCloze(
addKeyValue(key, value);
}
- text = `{{c${index + 1}::image-occlusion:${type}${text}}}
`;
+ let ordinal: number;
+ if (type === "text") {
+ ordinal = 0;
+ } else {
+ ordinal = index + 1;
+ }
+ text = `{{c${ordinal}::image-occlusion:${type}${text}}}
`;
+
return text;
}
diff --git a/ts/image-occlusion/tools/add-from-cloze.ts b/ts/image-occlusion/tools/add-from-cloze.ts
index 9fe75691d..c260f12ac 100644
--- a/ts/image-occlusion/tools/add-from-cloze.ts
+++ b/ts/image-occlusion/tools/add-from-cloze.ts
@@ -1,15 +1,19 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+import type { GetImageOcclusionNoteResponse_ImageOcclusion } from "@tslib/anki/image_occlusion_pb";
import { fabric } from "fabric";
import { extractShapesFromClozedField } from "image-occlusion/shapes/from-cloze";
import type { Size } from "../types";
-import { addBorder, disableRotation } from "./lib";
+import { addBorder, disableRotation, enableUniformScaling } from "./lib";
-export const addShapesToCanvasFromCloze = (canvas: fabric.Canvas, clozeStr: string): void => {
+export const addShapesToCanvasFromCloze = (
+ canvas: fabric.Canvas,
+ occlusions: GetImageOcclusionNoteResponse_ImageOcclusion[],
+): void => {
const size: Size = canvas;
- for (const shapeOrShapes of extractShapesFromClozedField(clozeStr)) {
+ for (const shapeOrShapes of extractShapesFromClozedField(occlusions)) {
if (Array.isArray(shapeOrShapes)) {
const group = new fabric.Group();
shapeOrShapes.map((shape) => {
@@ -23,6 +27,9 @@ export const addShapesToCanvasFromCloze = (canvas: fabric.Canvas, clozeStr: stri
const shape = shapeOrShapes.toFabric(size);
addBorder(shape);
disableRotation(shape);
+ if (shape.type === "i-text") {
+ enableUniformScaling(canvas, shape);
+ }
canvas.add(shape);
}
}
diff --git a/ts/image-occlusion/tools/index.ts b/ts/image-occlusion/tools/index.ts
index 1a90ac44f..1acc09163 100644
--- a/ts/image-occlusion/tools/index.ts
+++ b/ts/image-occlusion/tools/index.ts
@@ -4,5 +4,6 @@
import { drawEllipse } from "./tool-ellipse";
import { drawPolygon } from "./tool-polygon";
import { drawRectangle } from "./tool-rect";
+import { drawText } from "./tool-text";
-export { drawEllipse, drawPolygon, drawRectangle };
+export { drawEllipse, drawPolygon, drawRectangle, drawText };
diff --git a/ts/image-occlusion/tools/lib.ts b/ts/image-occlusion/tools/lib.ts
index 423a4cf42..bf0178851 100644
--- a/ts/image-occlusion/tools/lib.ts
+++ b/ts/image-occlusion/tools/lib.ts
@@ -9,6 +9,8 @@ import { zoomResetValue } from "../store";
export const SHAPE_MASK_COLOR = "#ffeba2";
export const BORDER_COLOR = "#212121";
+export const TEXT_FONT_FAMILY = "Arial";
+export const TEXT_PADDING = 5;
let _clipboard;
@@ -18,7 +20,10 @@ export const stopDraw = (canvas: fabric.Canvas): void => {
canvas.off("mouse:move");
};
-export const enableSelectable = (canvas: fabric.Canvas, select: boolean): void => {
+export const enableSelectable = (
+ canvas: fabric.Canvas,
+ select: boolean,
+): void => {
canvas.selection = select;
canvas.forEachObject(function(o) {
o.selectable = select;
@@ -135,7 +140,10 @@ const pasteItem = (canvas: fabric.Canvas): void => {
});
};
-export const makeMaskTransparent = (canvas: fabric.Canvas, opacity = false): void => {
+export const makeMaskTransparent = (
+ canvas: fabric.Canvas,
+ opacity = false,
+): void => {
const objects = canvas.getObjects();
objects.forEach((object) => {
object.set({
@@ -152,16 +160,25 @@ export const moveShapeToCanvasBoundaries = (canvas: fabric.Canvas): void => {
if (!activeObject) {
return;
}
- if (activeObject.type === "activeSelection" || activeObject.type === "rect") {
+ if (
+ activeObject.type === "activeSelection"
+ || activeObject.type === "rect"
+ ) {
modifiedSelection(canvas, activeObject);
}
if (activeObject.type === "ellipse") {
modifiedEllipse(canvas, activeObject);
}
+ if (activeObject.type === "i-text") {
+ modifiedText(canvas, activeObject);
+ }
});
};
-const modifiedSelection = (canvas: fabric.Canvas, object: fabric.Object): void => {
+const modifiedSelection = (
+ canvas: fabric.Canvas,
+ object: fabric.Object,
+): void => {
const newWidth = object.width * object.scaleX;
const newHeight = object.height * object.scaleY;
@@ -174,7 +191,10 @@ const modifiedSelection = (canvas: fabric.Canvas, object: fabric.Object): void =
setShapePosition(canvas, object);
};
-const modifiedEllipse = (canvas: fabric.Canvas, object: fabric.Object): void => {
+const modifiedEllipse = (
+ canvas: fabric.Canvas,
+ object: fabric.Object,
+): void => {
const newRx = object.rx * object.scaleX;
const newRy = object.ry * object.scaleY;
const newWidth = object.width * object.scaleX;
@@ -191,18 +211,25 @@ const modifiedEllipse = (canvas: fabric.Canvas, object: fabric.Object): void =>
setShapePosition(canvas, object);
};
-const setShapePosition = (canvas: fabric.Canvas, object: fabric.Object): void => {
+const modifiedText = (canvas: fabric.Canvas, object: fabric.Object): void => {
+ setShapePosition(canvas, object);
+};
+
+const setShapePosition = (
+ canvas: fabric.Canvas,
+ object: fabric.Object,
+): void => {
if (object.left < 0) {
object.set({ left: 0 });
}
if (object.top < 0) {
object.set({ top: 0 });
}
- if (object.left + object.width + object.strokeWidth > canvas.width) {
- object.set({ left: canvas.width - object.width });
+ if (object.left + object.width * object.scaleX + object.strokeWidth > canvas.width) {
+ object.set({ left: canvas.width - object.width * object.scaleX });
}
- if (object.top + object.height + object.strokeWidth > canvas.height) {
- object.set({ top: canvas.height - object.height });
+ if (object.top + object.height * object.scaleY + object.strokeWidth > canvas.height) {
+ object.set({ top: canvas.height - object.height * object.scaleY });
}
object.setCoords();
};
@@ -213,6 +240,20 @@ export function disableRotation(obj: fabric.Object): void {
});
}
+export function enableUniformScaling(canvas: fabric.Canvas, obj: fabric.Object): void {
+ obj.setControlsVisibility({ mb: false, ml: false, mt: false, mr: false });
+ let timer: number;
+ obj.on("scaling", (e) => {
+ if (["bl", "br", "tr", "tl"].includes(e.transform.corner)) {
+ clearTimeout(timer);
+ canvas.uniformScaling = true;
+ timer = setTimeout(() => {
+ canvas.uniformScaling = false;
+ }, 500);
+ }
+ });
+}
+
export function addBorder(obj: fabric.Object): void {
obj.stroke = BORDER_COLOR;
}
diff --git a/ts/image-occlusion/tools/tool-buttons.ts b/ts/image-occlusion/tools/tool-buttons.ts
index e3386d525..d2afb8fe8 100644
--- a/ts/image-occlusion/tools/tool-buttons.ts
+++ b/ts/image-occlusion/tools/tool-buttons.ts
@@ -6,6 +6,7 @@ import {
mdiEllipseOutline,
mdiMagnifyScan,
mdiRectangleOutline,
+ mdiTextBox,
mdiVectorPolygonVariant,
} from "../icons";
@@ -30,4 +31,8 @@ export const tools = [
id: "draw-polygon",
icon: mdiVectorPolygonVariant,
},
+ {
+ id: "draw-text",
+ icon: mdiTextBox,
+ },
];
diff --git a/ts/image-occlusion/tools/tool-text.ts b/ts/image-occlusion/tools/tool-text.ts
new file mode 100644
index 000000000..17fd922d5
--- /dev/null
+++ b/ts/image-occlusion/tools/tool-text.ts
@@ -0,0 +1,43 @@
+// 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 {
+ disableRotation,
+ enableUniformScaling,
+ SHAPE_MASK_COLOR,
+ stopDraw,
+ TEXT_FONT_FAMILY,
+ TEXT_PADDING,
+} from "./lib";
+import { undoStack } from "./tool-undo-redo";
+
+export const drawText = (canvas: fabric.Canvas): void => {
+ canvas.selectionColor = "rgba(0, 0, 0, 0)";
+ stopDraw(canvas);
+
+ canvas.on("mouse:down", function(o) {
+ if (o.target) {
+ return;
+ }
+ const pointer = canvas.getPointer(o.e);
+ const text = new fabric.IText("text", {
+ id: "text-" + new Date().getTime(),
+ left: pointer.x,
+ top: pointer.y,
+ originX: "left",
+ originY: "top",
+ selectable: true,
+ strokeWidth: 1,
+ noScaleCache: false,
+ fontFamily: TEXT_FONT_FAMILY,
+ backgroundColor: SHAPE_MASK_COLOR,
+ padding: TEXT_PADDING,
+ });
+ disableRotation(text);
+ enableUniformScaling(canvas, text);
+ canvas.add(text);
+ undoStack.onObjectAdded(text.id);
+ });
+};
diff --git a/ts/image-occlusion/tools/tool-undo-redo.ts b/ts/image-occlusion/tools/tool-undo-redo.ts
index 322c70abe..4e1ca2b72 100644
--- a/ts/image-occlusion/tools/tool-undo-redo.ts
+++ b/ts/image-occlusion/tools/tool-undo-redo.ts
@@ -17,7 +17,7 @@ type UndoState = {
redoable: boolean;
};
-const shapeType = ["rect", "ellipse"];
+const shapeType = ["rect", "ellipse", "i-text"];
const validShape = (shape: fabric.Object): boolean => {
if (shape.width <= 5 || shape.height <= 5) return false;
@@ -90,10 +90,7 @@ class UndoStack {
}
private maybePush(opts): void {
- if (
- !this.locked
- && validShape(opts.target as fabric.Object)
- ) {
+ if (!this.locked && validShape(opts.target as fabric.Object)) {
this.push();
}
}