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
This commit is contained in:
Abdo 2023-10-12 06:40:11 +03:00 committed by GitHub
parent b35a11ffd6
commit 9e147c6335
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 494 additions and 184 deletions

View file

@ -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;

View file

@ -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#"<div class="cloze" {}></div>"#,
&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<ImageOcclusion> {
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<str> {
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<u16> {
@ -389,8 +404,10 @@ pub fn cloze_numbers_in_string(html: &str) -> HashSet<u16> {
fn add_cloze_numbers_in_text_with_clozes(nodes: &[TextOrCloze], set: &mut HashSet<u16>) {
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);
}
}
}
}

View file

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

View file

@ -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<ImageOcclusion> {
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&#x3A;bar" data-left="10" "#,
);
}

View file

@ -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;
}

View file

@ -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";

View file

@ -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);

View file

@ -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";

View file

@ -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<string, any>] {
const parts = text.split(":");
const type = parts[0];
if (type !== "rect" && type !== "ellipse" && type !== "polygon") {
return [null, {}];
}
const props = {};
for (let i = 1; i < parts.length; i++) {
const [key, value] = parts[i].split("=");
props[key] = value;
}
return [type, props];
}
/** Locate all cloze divs in the review screen for the given selector, and convert them into BaseShapes.
*/
export function extractShapesFromRenderedClozes(selector: string): Shape[] {
@ -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<string, any>): 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<string, any>): Shape {
}
return new Polygon(props);
}
case "text": {
return new Text({
...props,
scaleX: parseFloat(props.scale),
scaleY: parseFloat(props.scale),
});
}
}
}

View file

@ -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<Text> = {}) {
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;
}

View file

@ -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}}}<br>`;
let ordinal: number;
if (type === "text") {
ordinal = 0;
} else {
ordinal = index + 1;
}
text = `{{c${ordinal}::image-occlusion:${type}${text}}}<br>`;
return text;
}

View file

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

View file

@ -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 };

View file

@ -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;
}

View file

@ -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,
},
];

View file

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

View file

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