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(); } }