From 9e147c63356be1a088c3fbcf2719de6a90ca22cc Mon Sep 17 00:00:00 2001 From: Abdo Date: Thu, 12 Oct 2023 06:40:11 +0300 Subject: [PATCH] Add text tool to image occlusion (#2705) * Add text tool to IO * Remove unnecessary parentheses * Fix text objects always grouped * Remove log * Fix text objects hidden on back side * Implement text scaling * Add inverse text outline * Warn about IO notes with only text objects This will result in a different error message than the case where no objects are added at all though, and the user can bypass the warning. Maybe this is better to avoid discarding the user's work if they have spent some time adding text. * Add isValidType * Use matches! * Lock aspect ratio of text objects * Reword misleading comment The confusion probably comes from the Fabric docs, which apparently need updating: http://fabricjs.com/docs/fabric.Canvas.html#uniformScaling * Do not count text objects when calculating current index * Make text objects respond to size changes * Fix uniform scaling not working when editing * Use Arial font * Escape colons and unify parsing * Handle scale factor when restricting shape to view * Use 'cloned' * Add text background * Tweak drawShape's params --- proto/anki/image_occlusion.proto | 12 +- rslib/src/cloze.rs | 27 ++- rslib/src/image_occlusion/imagedata.rs | 3 +- rslib/src/image_occlusion/imageocclusion.rs | 179 +++++++++++++------- ts/image-occlusion/Toolbar.svelte | 5 +- ts/image-occlusion/icons.ts | 1 + ts/image-occlusion/mask-editor.ts | 2 +- ts/image-occlusion/review.ts | 121 ++++++++++--- ts/image-occlusion/shapes/from-cloze.ts | 106 +++++------- ts/image-occlusion/shapes/text.ts | 65 +++++++ ts/image-occlusion/shapes/to-cloze.ts | 25 ++- ts/image-occlusion/tools/add-from-cloze.ts | 13 +- ts/image-occlusion/tools/index.ts | 3 +- ts/image-occlusion/tools/lib.ts | 61 +++++-- ts/image-occlusion/tools/tool-buttons.ts | 5 + ts/image-occlusion/tools/tool-text.ts | 43 +++++ ts/image-occlusion/tools/tool-undo-redo.ts | 7 +- 17 files changed, 494 insertions(+), 184 deletions(-) create mode 100644 ts/image-occlusion/shapes/text.ts create mode 100644 ts/image-occlusion/tools/tool-text.ts 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(); } }