mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
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:
parent
b35a11ffd6
commit
9e147c6335
17 changed files with 494 additions and 184 deletions
|
@ -54,9 +54,19 @@ message GetImageOcclusionNoteRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetImageOcclusionNoteResponse {
|
message GetImageOcclusionNoteResponse {
|
||||||
|
message ImageOcclusionProperty {
|
||||||
|
string name = 1;
|
||||||
|
string value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ImageOcclusion {
|
||||||
|
string shape = 1;
|
||||||
|
repeated ImageOcclusionProperty properties = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message ImageClozeNote {
|
message ImageClozeNote {
|
||||||
bytes image_data = 1;
|
bytes image_data = 1;
|
||||||
string occlusions = 2;
|
repeated ImageOcclusion occlusions = 2;
|
||||||
string header = 3;
|
string header = 3;
|
||||||
string back_extra = 4;
|
string back_extra = 4;
|
||||||
repeated string tags = 5;
|
repeated string tags = 5;
|
||||||
|
|
|
@ -5,6 +5,7 @@ use std::borrow::Cow;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusion;
|
||||||
use htmlescape::encode_attribute;
|
use htmlescape::encode_attribute;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use nom::branch::alt;
|
use nom::branch::alt;
|
||||||
|
@ -16,6 +17,7 @@ use regex::Captures;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use crate::image_occlusion::imageocclusion::get_image_cloze_data;
|
use crate::image_occlusion::imageocclusion::get_image_cloze_data;
|
||||||
|
use crate::image_occlusion::imageocclusion::parse_image_cloze;
|
||||||
use crate::latex::contains_latex;
|
use crate::latex::contains_latex;
|
||||||
use crate::template::RenderContext;
|
use crate::template::RenderContext;
|
||||||
use crate::text::strip_html_preserving_entities;
|
use crate::text::strip_html_preserving_entities;
|
||||||
|
@ -229,6 +231,7 @@ fn reveal_cloze(
|
||||||
image_occlusion_text,
|
image_occlusion_text,
|
||||||
question,
|
question,
|
||||||
active,
|
active,
|
||||||
|
cloze.ordinal,
|
||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -295,8 +298,8 @@ fn reveal_cloze(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_image_occlusion(text: &str, question_side: bool, active: bool) -> String {
|
fn render_image_occlusion(text: &str, question_side: bool, active: bool, ordinal: u16) -> String {
|
||||||
if question_side && active {
|
if (question_side && active) || ordinal == 0 {
|
||||||
format!(
|
format!(
|
||||||
r#"<div class="cloze" {}></div>"#,
|
r#"<div class="cloze" {}></div>"#,
|
||||||
&get_image_cloze_data(text)
|
&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> {
|
pub fn reveal_cloze_text(text: &str, cloze_ord: u16, question: bool) -> Cow<str> {
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
let mut active_cloze_found_in_text = false;
|
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 {
|
pub(crate) fn contains_cloze(text: &str) -> bool {
|
||||||
parse_text_with_clozes(text)
|
parse_text_with_clozes(text)
|
||||||
.iter()
|
.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> {
|
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>) {
|
fn add_cloze_numbers_in_text_with_clozes(nodes: &[TextOrCloze], set: &mut HashSet<u16>) {
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
if let TextOrCloze::Cloze(cloze) = node {
|
if let TextOrCloze::Cloze(cloze) = node {
|
||||||
set.insert(cloze.ordinal);
|
if !(cloze.image_occlusion().is_some() && cloze.ordinal == 0) {
|
||||||
add_cloze_numbers_in_text_with_clozes(&cloze.nodes, set);
|
set.insert(cloze.ordinal);
|
||||||
|
add_cloze_numbers_in_text_with_clozes(&cloze.nodes, set);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ use anki_proto::image_occlusion::ImageOcclusionFieldIndexes;
|
||||||
use anki_proto::notetypes::ImageOcclusionField;
|
use anki_proto::notetypes::ImageOcclusionField;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::cloze::parse_image_occlusions;
|
||||||
use crate::media::MediaManager;
|
use crate::media::MediaManager;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
@ -92,7 +93,7 @@ impl Collection {
|
||||||
.or_not_found(note.notetype_id)?;
|
.or_not_found(note.notetype_id)?;
|
||||||
let idxs = nt.get_io_field_indexes()?;
|
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.header = fields[idxs.header as usize].clone();
|
||||||
cloze_note.back_extra = fields[idxs.back_extra as usize].clone();
|
cloze_note.back_extra = fields[idxs.back_extra as usize].clone();
|
||||||
cloze_note.image_data = "".into();
|
cloze_note.image_data = "".into();
|
||||||
|
|
|
@ -3,6 +3,48 @@
|
||||||
|
|
||||||
use std::fmt::Write;
|
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
|
// convert text like
|
||||||
// rect:left=.2325:top=.3261:width=.202:height=.0975
|
// rect:left=.2325:top=.3261:width=.202:height=.0975
|
||||||
// to something like
|
// to something like
|
||||||
|
@ -10,72 +52,83 @@ use std::fmt::Write;
|
||||||
// data-width="167.09" data-height="33.78"
|
// data-width="167.09" data-height="33.78"
|
||||||
pub fn get_image_cloze_data(text: &str) -> String {
|
pub fn get_image_cloze_data(text: &str) -> String {
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
let parts: Vec<&str> = text.split(':').collect();
|
|
||||||
|
|
||||||
if parts.len() >= 2 {
|
if let Some(occlusion) = parse_image_cloze(text) {
|
||||||
if !parts[0].is_empty()
|
if !occlusion.shape.is_empty()
|
||||||
&& (parts[0] == "rect" || parts[0] == "ellipse" || parts[0] == "polygon")
|
&& 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 property in occlusion.properties {
|
||||||
for part in parts[1..].iter() {
|
match property.name.as_str() {
|
||||||
let values: Vec<&str> = part.split('=').collect();
|
"left" => {
|
||||||
if values.len() >= 2 {
|
if !property.value.is_empty() {
|
||||||
match values[0] {
|
result.push_str(&format!("data-left=\"{}\" ", property.value));
|
||||||
"left" => {
|
|
||||||
if !values[1].is_empty() {
|
|
||||||
result.push_str(&format!("data-left=\"{}\" ", values[1]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"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"),
|
get_image_cloze_data("polygon:points=0,0 10,10 20,0"),
|
||||||
r#"data-shape="polygon" data-points="0,0 10,10 20,0" "#,
|
r#"data-shape="polygon" data-points="0,0 10,10 20,0" "#,
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_image_cloze_data("text:text=foo\\:bar:left=10"),
|
||||||
|
r#"data-shape="text" data-text="foo:bar" data-left="10" "#,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { mdiEye, mdiFormatAlignCenter, mdiSquare, mdiViewDashboard } from "./icons";
|
||||||
import { hideAllGuessOne } from "./store";
|
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 { makeMaskTransparent } from "./tools/lib";
|
||||||
import { enableSelectable, stopDraw } from "./tools/lib";
|
import { enableSelectable, stopDraw } from "./tools/lib";
|
||||||
import {
|
import {
|
||||||
|
@ -58,6 +58,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
case "draw-polygon":
|
case "draw-polygon":
|
||||||
drawPolygon(canvas, instance);
|
drawPolygon(canvas, instance);
|
||||||
break;
|
break;
|
||||||
|
case "draw-text":
|
||||||
|
drawText(canvas);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 mdiRedo } from "@mdi/svg/svg/redo.svg";
|
||||||
export { default as mdiRefresh } from "@mdi/svg/svg/refresh.svg";
|
export { default as mdiRefresh } from "@mdi/svg/svg/refresh.svg";
|
||||||
export { default as mdiSquare } from "@mdi/svg/svg/square.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 mdiUndo } from "@mdi/svg/svg/undo.svg";
|
||||||
export { default as mdiUnfoldMoreHorizontal } from "@mdi/svg/svg/unfold-more-horizontal.svg";
|
export { default as mdiUnfoldMoreHorizontal } from "@mdi/svg/svg/unfold-more-horizontal.svg";
|
||||||
export { default as mdiUngroup } from "@mdi/svg/svg/ungroup.svg";
|
export { default as mdiUngroup } from "@mdi/svg/svg/ungroup.svg";
|
||||||
|
|
|
@ -90,7 +90,7 @@ function initCanvas(onChange: () => void): fabric.Canvas {
|
||||||
tagsWritable.set([]);
|
tagsWritable.set([]);
|
||||||
globalThis.canvas = canvas;
|
globalThis.canvas = canvas;
|
||||||
undoStack.setCanvas(canvas);
|
undoStack.setCanvas(canvas);
|
||||||
// enables uniform scaling by default without the need for the Shift key
|
// Disable uniform scaling
|
||||||
canvas.uniformScaling = false;
|
canvas.uniformScaling = false;
|
||||||
canvas.uniScaleKey = "none";
|
canvas.uniScaleKey = "none";
|
||||||
moveShapeToCanvasBoundaries(canvas);
|
moveShapeToCanvasBoundaries(canvas);
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { Ellipse } from "./shapes/ellipse";
|
||||||
import { extractShapesFromRenderedClozes } from "./shapes/from-cloze";
|
import { extractShapesFromRenderedClozes } from "./shapes/from-cloze";
|
||||||
import { Polygon } from "./shapes/polygon";
|
import { Polygon } from "./shapes/polygon";
|
||||||
import { Rectangle } from "./shapes/rectangle";
|
import { Rectangle } from "./shapes/rectangle";
|
||||||
|
import { Text } from "./shapes/text";
|
||||||
|
import { TEXT_FONT_FAMILY, TEXT_PADDING } from "./tools/lib";
|
||||||
import type { Size } from "./types";
|
import type { Size } from "./types";
|
||||||
|
|
||||||
export function setupImageCloze(): void {
|
export function setupImageCloze(): void {
|
||||||
|
@ -19,14 +21,20 @@ export function setupImageCloze(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupImageClozeInner(): 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) {
|
if (canvas == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
|
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
|
||||||
const container = document.getElementById("image-occlusion-container") as HTMLDivElement;
|
const container = document.getElementById(
|
||||||
const image = document.querySelector("#image-occlusion-container img") as HTMLImageElement;
|
"image-occlusion-container",
|
||||||
|
) as HTMLDivElement;
|
||||||
|
const image = document.querySelector(
|
||||||
|
"#image-occlusion-container img",
|
||||||
|
) as HTMLImageElement;
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
container.innerText = tr.notetypeErrorNoImageToShow();
|
container.innerText = tr.notetypeErrorNoImageToShow();
|
||||||
return;
|
return;
|
||||||
|
@ -51,17 +59,18 @@ function setupImageClozeInner(): void {
|
||||||
drawShapes(canvas, ctx);
|
drawShapes(canvas, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawShapes(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
|
function drawShapes(
|
||||||
const shapeProperty = getShapeProperty();
|
canvas: HTMLCanvasElement,
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
): void {
|
||||||
|
const properties = getShapeProperties();
|
||||||
const size = canvas;
|
const size = canvas;
|
||||||
for (const active of extractShapesFromRenderedClozes(".cloze")) {
|
for (const shape of extractShapesFromRenderedClozes(".cloze")) {
|
||||||
const fill = shapeProperty.activeShapeColor;
|
drawShape(ctx, size, shape, properties, true);
|
||||||
drawShape(ctx, size, active, fill, shapeProperty.activeBorder);
|
|
||||||
}
|
}
|
||||||
for (const inactive of extractShapesFromRenderedClozes(".cloze-inactive")) {
|
for (const shape of extractShapesFromRenderedClozes(".cloze-inactive")) {
|
||||||
const fill = shapeProperty.inActiveShapeColor;
|
if (shape.occludeInactive) {
|
||||||
if (inactive.occludeInactive) {
|
drawShape(ctx, size, shape, properties, false);
|
||||||
drawShape(ctx, size, inactive, fill, shapeProperty.inActiveBorder);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -70,10 +79,21 @@ function drawShape(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
size: Size,
|
size: Size,
|
||||||
shape: Shape,
|
shape: Shape,
|
||||||
color: string,
|
properties: ShapeProperties,
|
||||||
border: { width: number; color: string },
|
active: boolean,
|
||||||
): void {
|
): void {
|
||||||
shape.makeAbsolute(size);
|
shape.makeAbsolute(size);
|
||||||
|
|
||||||
|
const { color, border } = active
|
||||||
|
? {
|
||||||
|
color: properties.activeShapeColor,
|
||||||
|
border: properties.activeBorder,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
color: properties.inActiveShapeColor,
|
||||||
|
border: properties.inActiveBorder,
|
||||||
|
};
|
||||||
|
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.strokeStyle = border.color;
|
ctx.strokeStyle = border.color;
|
||||||
ctx.lineWidth = border.width;
|
ctx.lineWidth = border.width;
|
||||||
|
@ -84,7 +104,16 @@ function drawShape(
|
||||||
const adjustedLeft = shape.left + shape.rx;
|
const adjustedLeft = shape.left + shape.rx;
|
||||||
const adjustedTop = shape.top + shape.ry;
|
const adjustedTop = shape.top + shape.ry;
|
||||||
ctx.beginPath();
|
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.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
@ -101,6 +130,26 @@ function drawShape(
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
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 };
|
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 top = points[0].y;
|
||||||
let left = points[0].x;
|
let left = points[0].x;
|
||||||
for (const point of points) {
|
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 };
|
return { x: left, y: top };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getShapeProperty(): {
|
type ShapeProperties = {
|
||||||
activeShapeColor: string;
|
activeShapeColor: string;
|
||||||
inActiveShapeColor: string;
|
inActiveShapeColor: string;
|
||||||
activeBorder: { width: number; color: string };
|
activeBorder: { width: number; color: string };
|
||||||
inActiveBorder: { width: number; color: string };
|
inActiveBorder: { width: number; color: string };
|
||||||
} {
|
};
|
||||||
|
function getShapeProperties(): ShapeProperties {
|
||||||
const canvas = document.getElementById("image-occlusion-canvas");
|
const canvas = document.getElementById("image-occlusion-canvas");
|
||||||
const computedStyle = window.getComputedStyle(canvas!);
|
const computedStyle = window.getComputedStyle(canvas!);
|
||||||
// it may throw error if the css variable is not defined
|
// it may throw error if the css variable is not defined
|
||||||
try {
|
try {
|
||||||
// shape color
|
// shape color
|
||||||
const activeShapeColor = computedStyle.getPropertyValue("--active-shape-color");
|
const activeShapeColor = computedStyle.getPropertyValue(
|
||||||
const inActiveShapeColor = computedStyle.getPropertyValue("--inactive-shape-color");
|
"--active-shape-color",
|
||||||
|
);
|
||||||
|
const inActiveShapeColor = computedStyle.getPropertyValue(
|
||||||
|
"--inactive-shape-color",
|
||||||
|
);
|
||||||
// inactive shape border
|
// 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 inActiveBorder = inActiveShapeBorder.split(" ").filter((x) => x);
|
||||||
const inActiveShapeBorderWidth = parseFloat(inActiveBorder[0]);
|
const inActiveShapeBorderWidth = parseFloat(inActiveBorder[0]);
|
||||||
const inActiveShapeBorderColor = inActiveBorder[1];
|
const inActiveShapeBorderColor = inActiveBorder[1];
|
||||||
// active shape border
|
// 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 activeBorder = activeShapeBorder.split(" ").filter((x) => x);
|
||||||
const activeShapeBorderWidth = parseFloat(activeBorder[0]);
|
const activeShapeBorderWidth = parseFloat(activeBorder[0]);
|
||||||
const activeShapeBorderColor = activeBorder[1];
|
const activeShapeBorderColor = activeBorder[1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeShapeColor: activeShapeColor ? activeShapeColor : "#ff8e8e",
|
activeShapeColor: activeShapeColor ? activeShapeColor : "#ff8e8e",
|
||||||
inActiveShapeColor: inActiveShapeColor ? inActiveShapeColor : "#ffeba2",
|
inActiveShapeColor: inActiveShapeColor
|
||||||
|
? inActiveShapeColor
|
||||||
|
: "#ffeba2",
|
||||||
activeBorder: {
|
activeBorder: {
|
||||||
width: activeShapeBorderWidth ? activeShapeBorderWidth : 1,
|
width: activeShapeBorderWidth ? activeShapeBorderWidth : 1,
|
||||||
color: activeShapeBorderColor ? activeShapeBorderColor : "#212121",
|
color: activeShapeBorderColor
|
||||||
|
? activeShapeBorderColor
|
||||||
|
: "#212121",
|
||||||
},
|
},
|
||||||
inActiveBorder: {
|
inActiveBorder: {
|
||||||
width: inActiveShapeBorderWidth ? inActiveShapeBorderWidth : 1,
|
width: inActiveShapeBorderWidth ? inActiveShapeBorderWidth : 1,
|
||||||
color: inActiveShapeBorderColor ? inActiveShapeBorderColor : "#212121",
|
color: inActiveShapeBorderColor
|
||||||
|
? inActiveShapeBorderColor
|
||||||
|
: "#212121",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -177,7 +244,9 @@ function getShapeProperty(): {
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleMasks = (): void => {
|
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;
|
const display = canvas.style.display;
|
||||||
if (display === "none") {
|
if (display === "none") {
|
||||||
canvas.style.display = "unset";
|
canvas.style.display = "unset";
|
||||||
|
|
|
@ -5,76 +5,28 @@
|
||||||
@typescript-eslint/no-explicit-any: "off",
|
@typescript-eslint/no-explicit-any: "off",
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { GetImageOcclusionNoteResponse_ImageOcclusion } from "@tslib/anki/image_occlusion_pb";
|
||||||
|
|
||||||
import type { Shape, ShapeOrShapes } from "./base";
|
import type { Shape, ShapeOrShapes } from "./base";
|
||||||
import { Ellipse } from "./ellipse";
|
import { Ellipse } from "./ellipse";
|
||||||
import { Point, Polygon } from "./polygon";
|
import { Point, Polygon } from "./polygon";
|
||||||
import { Rectangle } from "./rectangle";
|
import { Rectangle } from "./rectangle";
|
||||||
|
import { Text } from "./text";
|
||||||
|
|
||||||
/** Given a cloze field with text like the following, extract the shapes from it:
|
export function extractShapesFromClozedField(
|
||||||
* {{c1::image-occlusion:rect:left=10.0:top=20:width=30:height=10:fill=#ffe34d}}
|
occlusions: GetImageOcclusionNoteResponse_ImageOcclusion[],
|
||||||
*/
|
): ShapeOrShapes[] {
|
||||||
export function extractShapesFromClozedField(clozeStr: string): ShapeOrShapes[] {
|
|
||||||
const regex = /{{(.*?)}}/g;
|
|
||||||
const clozeStrList: string[] = [];
|
|
||||||
let match: string[] | null;
|
|
||||||
|
|
||||||
while ((match = regex.exec(clozeStr)) !== null) {
|
|
||||||
clozeStrList.push(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clozeList = {};
|
|
||||||
for (const str of clozeStrList) {
|
|
||||||
const [prefix, value] = str.split("::image-occlusion:");
|
|
||||||
if (!clozeList[prefix]) {
|
|
||||||
clozeList[prefix] = [];
|
|
||||||
}
|
|
||||||
clozeList[prefix].push(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output: ShapeOrShapes[] = [];
|
const output: ShapeOrShapes[] = [];
|
||||||
|
for (const occlusion of occlusions) {
|
||||||
for (const index in clozeList) {
|
if (isValidType(occlusion.shape)) {
|
||||||
if (clozeList[index].length > 1) {
|
const props = Object.fromEntries(occlusion.properties.map(prop => [prop.name, prop.value]));
|
||||||
const group: Shape[] = [];
|
output.push(buildShape(occlusion.shape, props));
|
||||||
clozeList[index].forEach((cloze) => {
|
|
||||||
let shape: Shape | null = null;
|
|
||||||
if ((shape = extractShapeFromClozeText(cloze))) {
|
|
||||||
group.push(shape);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
output.push(group);
|
|
||||||
} else {
|
|
||||||
let shape: Shape | null = null;
|
|
||||||
if ((shape = extractShapeFromClozeText(clozeList[index][0]))) {
|
|
||||||
output.push(shape);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
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.
|
/** Locate all cloze divs in the review screen for the given selector, and convert them into BaseShapes.
|
||||||
*/
|
*/
|
||||||
export function extractShapesFromRenderedClozes(selector: string): Shape[] {
|
export function extractShapesFromRenderedClozes(selector: string): Shape[] {
|
||||||
|
@ -89,7 +41,12 @@ export function extractShapesFromRenderedClozes(selector: string): Shape[] {
|
||||||
|
|
||||||
function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
|
function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
|
||||||
const type = cloze.dataset.shape!;
|
const type = cloze.dataset.shape!;
|
||||||
if (type !== "rect" && type !== "ellipse" && type !== "polygon") {
|
if (
|
||||||
|
type !== "rect"
|
||||||
|
&& type !== "ellipse"
|
||||||
|
&& type !== "polygon"
|
||||||
|
&& type !== "text"
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const props = {
|
const props = {
|
||||||
|
@ -101,18 +58,32 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
|
||||||
rx: cloze.dataset.rx,
|
rx: cloze.dataset.rx,
|
||||||
ry: cloze.dataset.ry,
|
ry: cloze.dataset.ry,
|
||||||
points: cloze.dataset.points,
|
points: cloze.dataset.points,
|
||||||
|
text: cloze.dataset.text,
|
||||||
|
scale: cloze.dataset.scale,
|
||||||
};
|
};
|
||||||
return buildShape(type, props);
|
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 {
|
function buildShape(type: ShapeType, props: Record<string, any>): Shape {
|
||||||
props.left = parseFloat(Number.isNaN(Number(props.left)) ? ".0000" : props.left);
|
props.left = parseFloat(
|
||||||
props.top = parseFloat(Number.isNaN(Number(props.top)) ? ".0000" : props.top);
|
Number.isNaN(Number(props.left)) ? ".0000" : props.left,
|
||||||
|
);
|
||||||
|
props.top = parseFloat(
|
||||||
|
Number.isNaN(Number(props.top)) ? ".0000" : props.top,
|
||||||
|
);
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "rect": {
|
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": {
|
case "ellipse": {
|
||||||
return new Ellipse({
|
return new Ellipse({
|
||||||
|
@ -132,5 +103,12 @@ function buildShape(type: ShapeType, props: Record<string, any>): Shape {
|
||||||
}
|
}
|
||||||
return new Polygon(props);
|
return new Polygon(props);
|
||||||
}
|
}
|
||||||
|
case "text": {
|
||||||
|
return new Text({
|
||||||
|
...props,
|
||||||
|
scaleX: parseFloat(props.scale),
|
||||||
|
scaleY: parseFloat(props.scale),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
65
ts/image-occlusion/shapes/text.ts
Normal file
65
ts/image-occlusion/shapes/text.ts
Normal 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;
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import type { Shape, ShapeOrShapes } from "./base";
|
||||||
import { Ellipse } from "./ellipse";
|
import { Ellipse } from "./ellipse";
|
||||||
import { Polygon } from "./polygon";
|
import { Polygon } from "./polygon";
|
||||||
import { Rectangle } from "./rectangle";
|
import { Rectangle } from "./rectangle";
|
||||||
|
import { Text } from "./text";
|
||||||
|
|
||||||
export function exportShapesToClozeDeletions(occludeInactive: boolean): {
|
export function exportShapesToClozeDeletions(occludeInactive: boolean): {
|
||||||
clozes: string;
|
clozes: string;
|
||||||
|
@ -19,8 +20,12 @@ export function exportShapesToClozeDeletions(occludeInactive: boolean): {
|
||||||
const shapes = baseShapesFromFabric(occludeInactive);
|
const shapes = baseShapesFromFabric(occludeInactive);
|
||||||
|
|
||||||
let clozes = "";
|
let clozes = "";
|
||||||
shapes.forEach((shapeOrShapes, index) => {
|
let index = 0;
|
||||||
|
shapes.forEach((shapeOrShapes) => {
|
||||||
clozes += shapeOrShapesToCloze(shapeOrShapes, index);
|
clozes += shapeOrShapesToCloze(shapeOrShapes, index);
|
||||||
|
if (!(shapeOrShapes instanceof Text)) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { clozes, noteCount: shapes.length };
|
return { clozes, noteCount: shapes.length };
|
||||||
|
@ -67,6 +72,9 @@ function fabricObjectToBaseShapeOrShapes(
|
||||||
case "polygon":
|
case "polygon":
|
||||||
shape = new Polygon(cloned);
|
shape = new Polygon(cloned);
|
||||||
break;
|
break;
|
||||||
|
case "i-text":
|
||||||
|
shape = new Text(cloned);
|
||||||
|
break;
|
||||||
case "group":
|
case "group":
|
||||||
return object._objects.map((child) => {
|
return object._objects.map((child) => {
|
||||||
return fabricObjectToBaseShapeOrShapes(
|
return fabricObjectToBaseShapeOrShapes(
|
||||||
|
@ -101,9 +109,7 @@ function shapeOrShapesToCloze(
|
||||||
): string {
|
): string {
|
||||||
let text = "";
|
let text = "";
|
||||||
function addKeyValue(key: string, value: string) {
|
function addKeyValue(key: string, value: string) {
|
||||||
if (key !== "points" && Number.isNaN(Number(value))) {
|
value = value.replace(":", "\\:");
|
||||||
value = ".0000";
|
|
||||||
}
|
|
||||||
text += `:${key}=${value}`;
|
text += `:${key}=${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,6 +124,8 @@ function shapeOrShapesToCloze(
|
||||||
type = "ellipse";
|
type = "ellipse";
|
||||||
} else if (shapeOrShapes instanceof Polygon) {
|
} else if (shapeOrShapes instanceof Polygon) {
|
||||||
type = "polygon";
|
type = "polygon";
|
||||||
|
} else if (shapeOrShapes instanceof Text) {
|
||||||
|
type = "text";
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Unknown shape type");
|
throw new Error("Unknown shape type");
|
||||||
}
|
}
|
||||||
|
@ -126,6 +134,13 @@ function shapeOrShapesToCloze(
|
||||||
addKeyValue(key, value);
|
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;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// 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 { fabric } from "fabric";
|
||||||
import { extractShapesFromClozedField } from "image-occlusion/shapes/from-cloze";
|
import { extractShapesFromClozedField } from "image-occlusion/shapes/from-cloze";
|
||||||
|
|
||||||
import type { Size } from "../types";
|
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;
|
const size: Size = canvas;
|
||||||
for (const shapeOrShapes of extractShapesFromClozedField(clozeStr)) {
|
for (const shapeOrShapes of extractShapesFromClozedField(occlusions)) {
|
||||||
if (Array.isArray(shapeOrShapes)) {
|
if (Array.isArray(shapeOrShapes)) {
|
||||||
const group = new fabric.Group();
|
const group = new fabric.Group();
|
||||||
shapeOrShapes.map((shape) => {
|
shapeOrShapes.map((shape) => {
|
||||||
|
@ -23,6 +27,9 @@ export const addShapesToCanvasFromCloze = (canvas: fabric.Canvas, clozeStr: stri
|
||||||
const shape = shapeOrShapes.toFabric(size);
|
const shape = shapeOrShapes.toFabric(size);
|
||||||
addBorder(shape);
|
addBorder(shape);
|
||||||
disableRotation(shape);
|
disableRotation(shape);
|
||||||
|
if (shape.type === "i-text") {
|
||||||
|
enableUniformScaling(canvas, shape);
|
||||||
|
}
|
||||||
canvas.add(shape);
|
canvas.add(shape);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,5 +4,6 @@
|
||||||
import { drawEllipse } from "./tool-ellipse";
|
import { drawEllipse } from "./tool-ellipse";
|
||||||
import { drawPolygon } from "./tool-polygon";
|
import { drawPolygon } from "./tool-polygon";
|
||||||
import { drawRectangle } from "./tool-rect";
|
import { drawRectangle } from "./tool-rect";
|
||||||
|
import { drawText } from "./tool-text";
|
||||||
|
|
||||||
export { drawEllipse, drawPolygon, drawRectangle };
|
export { drawEllipse, drawPolygon, drawRectangle, drawText };
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { zoomResetValue } from "../store";
|
||||||
|
|
||||||
export const SHAPE_MASK_COLOR = "#ffeba2";
|
export const SHAPE_MASK_COLOR = "#ffeba2";
|
||||||
export const BORDER_COLOR = "#212121";
|
export const BORDER_COLOR = "#212121";
|
||||||
|
export const TEXT_FONT_FAMILY = "Arial";
|
||||||
|
export const TEXT_PADDING = 5;
|
||||||
|
|
||||||
let _clipboard;
|
let _clipboard;
|
||||||
|
|
||||||
|
@ -18,7 +20,10 @@ export const stopDraw = (canvas: fabric.Canvas): void => {
|
||||||
canvas.off("mouse:move");
|
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.selection = select;
|
||||||
canvas.forEachObject(function(o) {
|
canvas.forEachObject(function(o) {
|
||||||
o.selectable = select;
|
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();
|
const objects = canvas.getObjects();
|
||||||
objects.forEach((object) => {
|
objects.forEach((object) => {
|
||||||
object.set({
|
object.set({
|
||||||
|
@ -152,16 +160,25 @@ export const moveShapeToCanvasBoundaries = (canvas: fabric.Canvas): void => {
|
||||||
if (!activeObject) {
|
if (!activeObject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeObject.type === "activeSelection" || activeObject.type === "rect") {
|
if (
|
||||||
|
activeObject.type === "activeSelection"
|
||||||
|
|| activeObject.type === "rect"
|
||||||
|
) {
|
||||||
modifiedSelection(canvas, activeObject);
|
modifiedSelection(canvas, activeObject);
|
||||||
}
|
}
|
||||||
if (activeObject.type === "ellipse") {
|
if (activeObject.type === "ellipse") {
|
||||||
modifiedEllipse(canvas, activeObject);
|
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 newWidth = object.width * object.scaleX;
|
||||||
const newHeight = object.height * object.scaleY;
|
const newHeight = object.height * object.scaleY;
|
||||||
|
|
||||||
|
@ -174,7 +191,10 @@ const modifiedSelection = (canvas: fabric.Canvas, object: fabric.Object): void =
|
||||||
setShapePosition(canvas, object);
|
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 newRx = object.rx * object.scaleX;
|
||||||
const newRy = object.ry * object.scaleY;
|
const newRy = object.ry * object.scaleY;
|
||||||
const newWidth = object.width * object.scaleX;
|
const newWidth = object.width * object.scaleX;
|
||||||
|
@ -191,18 +211,25 @@ const modifiedEllipse = (canvas: fabric.Canvas, object: fabric.Object): void =>
|
||||||
setShapePosition(canvas, object);
|
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) {
|
if (object.left < 0) {
|
||||||
object.set({ left: 0 });
|
object.set({ left: 0 });
|
||||||
}
|
}
|
||||||
if (object.top < 0) {
|
if (object.top < 0) {
|
||||||
object.set({ top: 0 });
|
object.set({ top: 0 });
|
||||||
}
|
}
|
||||||
if (object.left + object.width + object.strokeWidth > canvas.width) {
|
if (object.left + object.width * object.scaleX + object.strokeWidth > canvas.width) {
|
||||||
object.set({ left: canvas.width - object.width });
|
object.set({ left: canvas.width - object.width * object.scaleX });
|
||||||
}
|
}
|
||||||
if (object.top + object.height + object.strokeWidth > canvas.height) {
|
if (object.top + object.height * object.scaleY + object.strokeWidth > canvas.height) {
|
||||||
object.set({ top: canvas.height - object.height });
|
object.set({ top: canvas.height - object.height * object.scaleY });
|
||||||
}
|
}
|
||||||
object.setCoords();
|
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 {
|
export function addBorder(obj: fabric.Object): void {
|
||||||
obj.stroke = BORDER_COLOR;
|
obj.stroke = BORDER_COLOR;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
mdiEllipseOutline,
|
mdiEllipseOutline,
|
||||||
mdiMagnifyScan,
|
mdiMagnifyScan,
|
||||||
mdiRectangleOutline,
|
mdiRectangleOutline,
|
||||||
|
mdiTextBox,
|
||||||
mdiVectorPolygonVariant,
|
mdiVectorPolygonVariant,
|
||||||
} from "../icons";
|
} from "../icons";
|
||||||
|
|
||||||
|
@ -30,4 +31,8 @@ export const tools = [
|
||||||
id: "draw-polygon",
|
id: "draw-polygon",
|
||||||
icon: mdiVectorPolygonVariant,
|
icon: mdiVectorPolygonVariant,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "draw-text",
|
||||||
|
icon: mdiTextBox,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
43
ts/image-occlusion/tools/tool-text.ts
Normal file
43
ts/image-occlusion/tools/tool-text.ts
Normal 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);
|
||||||
|
});
|
||||||
|
};
|
|
@ -17,7 +17,7 @@ type UndoState = {
|
||||||
redoable: boolean;
|
redoable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shapeType = ["rect", "ellipse"];
|
const shapeType = ["rect", "ellipse", "i-text"];
|
||||||
|
|
||||||
const validShape = (shape: fabric.Object): boolean => {
|
const validShape = (shape: fabric.Object): boolean => {
|
||||||
if (shape.width <= 5 || shape.height <= 5) return false;
|
if (shape.width <= 5 || shape.height <= 5) return false;
|
||||||
|
@ -90,10 +90,7 @@ class UndoStack {
|
||||||
}
|
}
|
||||||
|
|
||||||
private maybePush(opts): void {
|
private maybePush(opts): void {
|
||||||
if (
|
if (!this.locked && validShape(opts.target as fabric.Object)) {
|
||||||
!this.locked
|
|
||||||
&& validShape(opts.target as fabric.Object)
|
|
||||||
) {
|
|
||||||
this.push();
|
this.push();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue