mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Add APIs for IO card rendering (#2739)
* Refactor: Add index to shapes package * Add shape draw callback API to setupImageCloze * Expose IO drawing API, switch away from image cloze naming We currently use "image occlusion" in most places, but some references to "image cloze" still remain. For consistency's sake and to make it easier to quickly find IO-related code, this commit replaces all remaining references to "image cloze", only maintaining those required for backwards compatibility with existing note types. * Add cloze ordinal to shapes * Do not mutate original shapes during (de)normalization Mutating shapes would be a recipe for trouble when combined with IO API use by external consumers. (makeNormal(makeAbsolute(makeNormal())) is not idempotent, and keeping track of the original state would introduce additional complexity with no discernible performance benefit or otherwise.) * Tweak IO API, allowing modifications to ShapeProperties * Tweak drawShape parameters * Switch method order For consistency with previous implementation * Run Rust formatters * Simplify position (de)normalization --------- Co-authored-by: Glutanimate <glutanimate@users.noreply.github.com>
This commit is contained in:
parent
0e24532439
commit
c828a2eb6f
14 changed files with 228 additions and 105 deletions
|
@ -64,7 +64,7 @@ message GetImageOcclusionNoteResponse {
|
|||
repeated ImageOcclusionProperty properties = 2;
|
||||
}
|
||||
|
||||
message ImageClozeNote {
|
||||
message ImageOcclusionNote {
|
||||
bytes image_data = 1;
|
||||
repeated ImageOcclusion occlusions = 2;
|
||||
string header = 3;
|
||||
|
@ -73,7 +73,7 @@ message GetImageOcclusionNoteResponse {
|
|||
}
|
||||
|
||||
oneof value {
|
||||
ImageClozeNote note = 1;
|
||||
ImageOcclusionNote note = 1;
|
||||
string error = 2;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ use std::path::PathBuf;
|
|||
|
||||
use anki_io::metadata;
|
||||
use anki_io::read_file;
|
||||
use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageClozeNote;
|
||||
use anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionNote;
|
||||
use anki_proto::image_occlusion::get_image_occlusion_note_response::Value;
|
||||
use anki_proto::image_occlusion::AddImageOcclusionNoteRequest;
|
||||
use anki_proto::image_occlusion::GetImageForOcclusionResponse;
|
||||
|
@ -82,9 +82,12 @@ impl Collection {
|
|||
Ok(GetImageOcclusionNoteResponse { value: Some(value) })
|
||||
}
|
||||
|
||||
pub fn get_image_occlusion_note_inner(&mut self, note_id: NoteId) -> Result<ImageClozeNote> {
|
||||
pub fn get_image_occlusion_note_inner(
|
||||
&mut self,
|
||||
note_id: NoteId,
|
||||
) -> Result<ImageOcclusionNote> {
|
||||
let note = self.storage.get_note(note_id)?.or_not_found(note_id)?;
|
||||
let mut cloze_note = ImageClozeNote::default();
|
||||
let mut cloze_note = ImageOcclusionNote::default();
|
||||
|
||||
let fields = note.fields();
|
||||
|
||||
|
|
|
@ -99,7 +99,7 @@ pub(crate) fn image_occlusion_notetype(tr: &I18n) -> Notetype {
|
|||
</div>
|
||||
<script>
|
||||
try {{
|
||||
anki.setupImageCloze();
|
||||
anki.imageOcclusion.setup();
|
||||
}} catch (exc) {{
|
||||
document.getElementById("err").innerHTML = `{err_loading}<br><br>${{exc}}`;
|
||||
}}
|
||||
|
|
|
@ -4,23 +4,50 @@
|
|||
import * as tr from "@tslib/ftl";
|
||||
|
||||
import { optimumPixelSizeForCanvas } from "./canvas-scale";
|
||||
import type { Shape } from "./shapes/base";
|
||||
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 { Shape } from "./shapes";
|
||||
import { Ellipse, extractShapesFromRenderedClozes, Polygon, Rectangle, Text } from "./shapes";
|
||||
import { TEXT_BACKGROUND_COLOR, TEXT_FONT_FAMILY, TEXT_PADDING } from "./tools/lib";
|
||||
import type { Size } from "./types";
|
||||
|
||||
export function setupImageCloze(): void {
|
||||
window.addEventListener("load", () => {
|
||||
window.addEventListener("resize", setupImageCloze);
|
||||
});
|
||||
window.requestAnimationFrame(setupImageClozeInner);
|
||||
export type DrawShapesData = {
|
||||
activeShapes: Shape[];
|
||||
inactiveShapes: Shape[];
|
||||
properties: ShapeProperties;
|
||||
};
|
||||
|
||||
export type DrawShapesFilter = (
|
||||
data: DrawShapesData,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => DrawShapesData | void;
|
||||
|
||||
export type DrawShapesCallback = (
|
||||
data: DrawShapesData,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => void;
|
||||
|
||||
export const imageOcclusionAPI = {
|
||||
setup: setupImageOcclusion,
|
||||
drawShape,
|
||||
Ellipse,
|
||||
Polygon,
|
||||
Rectangle,
|
||||
Shape,
|
||||
Text,
|
||||
};
|
||||
|
||||
interface SetupImageOcclusionOptions {
|
||||
onWillDrawShapes?: DrawShapesFilter;
|
||||
onDidDrawShapes?: DrawShapesCallback;
|
||||
}
|
||||
|
||||
function setupImageClozeInner(): void {
|
||||
function setupImageOcclusion(setupOptions?: SetupImageOcclusionOptions): void {
|
||||
window.addEventListener("load", () => {
|
||||
window.addEventListener("resize", () => setupImageOcclusion(setupOptions));
|
||||
});
|
||||
window.requestAnimationFrame(() => setupImageOcclusionInner(setupOptions));
|
||||
}
|
||||
|
||||
function setupImageOcclusionInner(setupOptions?: SetupImageOcclusionOptions): void {
|
||||
const canvas = document.querySelector(
|
||||
"#image-occlusion-canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
|
@ -28,7 +55,6 @@ function setupImageClozeInner(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
|
||||
const container = document.getElementById(
|
||||
"image-occlusion-container",
|
||||
) as HTMLDivElement;
|
||||
|
@ -56,47 +82,74 @@ function setupImageClozeInner(): void {
|
|||
button.addEventListener("click", toggleMasks);
|
||||
}
|
||||
|
||||
drawShapes(canvas, ctx);
|
||||
drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes);
|
||||
}
|
||||
|
||||
function drawShapes(
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
onWillDrawShapes?: DrawShapesFilter,
|
||||
onDidDrawShapes?: DrawShapesCallback,
|
||||
): void {
|
||||
const properties = getShapeProperties();
|
||||
const context: CanvasRenderingContext2D = canvas.getContext("2d")!;
|
||||
const size = canvas;
|
||||
for (const shape of extractShapesFromRenderedClozes(".cloze")) {
|
||||
drawShape(ctx, size, shape, properties, true);
|
||||
|
||||
let activeShapes = extractShapesFromRenderedClozes(".cloze");
|
||||
let inactiveShapes = extractShapesFromRenderedClozes(".cloze-inactive");
|
||||
let properties = getShapeProperties();
|
||||
|
||||
const processed = onWillDrawShapes?.({ activeShapes, inactiveShapes, properties }, context);
|
||||
if (processed) {
|
||||
activeShapes = processed.activeShapes;
|
||||
inactiveShapes = processed.inactiveShapes;
|
||||
properties = processed.properties;
|
||||
}
|
||||
for (const shape of extractShapesFromRenderedClozes(".cloze-inactive")) {
|
||||
if (shape.occludeInactive) {
|
||||
drawShape(ctx, size, shape, properties, false);
|
||||
}
|
||||
|
||||
for (const shape of activeShapes) {
|
||||
drawShape({
|
||||
context,
|
||||
size,
|
||||
shape,
|
||||
fill: properties.activeShapeColor,
|
||||
stroke: properties.activeBorder.color,
|
||||
strokeWidth: properties.activeBorder.width,
|
||||
});
|
||||
}
|
||||
for (const shape of inactiveShapes.filter((s) => s.occludeInactive)) {
|
||||
drawShape({
|
||||
context,
|
||||
size,
|
||||
shape,
|
||||
fill: properties.inActiveShapeColor,
|
||||
stroke: properties.inActiveBorder.color,
|
||||
strokeWidth: properties.inActiveBorder.width,
|
||||
});
|
||||
}
|
||||
|
||||
onDidDrawShapes?.({ activeShapes, inactiveShapes, properties }, context);
|
||||
}
|
||||
|
||||
function drawShape(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
size: Size,
|
||||
shape: Shape,
|
||||
properties: ShapeProperties,
|
||||
active: boolean,
|
||||
): void {
|
||||
shape.makeAbsolute(size);
|
||||
interface DrawShapeParameters {
|
||||
context: CanvasRenderingContext2D;
|
||||
size: Size;
|
||||
shape: Shape;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
const { color, border } = active
|
||||
? {
|
||||
color: properties.activeShapeColor,
|
||||
border: properties.activeBorder,
|
||||
}
|
||||
: {
|
||||
color: properties.inActiveShapeColor,
|
||||
border: properties.inActiveBorder,
|
||||
};
|
||||
function drawShape({
|
||||
context: ctx,
|
||||
size,
|
||||
shape,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
}: DrawShapeParameters): void {
|
||||
shape = shape.toAbsolute(size);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = border.color;
|
||||
ctx.lineWidth = border.width;
|
||||
ctx.fillStyle = fill;
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
if (shape instanceof Rectangle) {
|
||||
ctx.fillRect(shape.left, shape.top, shape.width, shape.height);
|
||||
ctx.strokeRect(shape.left, shape.top, shape.width, shape.height);
|
||||
|
@ -175,7 +228,7 @@ function topLeftOfPoints(points: { x: number; y: number }[]): {
|
|||
return { x: left, y: top };
|
||||
}
|
||||
|
||||
type ShapeProperties = {
|
||||
export type ShapeProperties = {
|
||||
activeShapeColor: string;
|
||||
inActiveShapeColor: string;
|
||||
activeBorder: { width: number; color: string };
|
||||
|
|
|
@ -23,14 +23,18 @@ export class Shape {
|
|||
* question side.
|
||||
*/
|
||||
occludeInactive = false;
|
||||
/* Cloze ordinal */
|
||||
ordinal = 0;
|
||||
|
||||
constructor(
|
||||
{ left = 0, top = 0, fill = SHAPE_MASK_COLOR, occludeInactive = false }: ConstructorParams<Shape> = {},
|
||||
{ left = 0, top = 0, fill = SHAPE_MASK_COLOR, occludeInactive = false, ordinal = 0 }: ConstructorParams<Shape> =
|
||||
{},
|
||||
) {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.fill = fill;
|
||||
this.occludeInactive = occludeInactive;
|
||||
this.ordinal = ordinal;
|
||||
}
|
||||
|
||||
/** Format numbers and remove default values, for easier serialization to
|
||||
|
@ -46,18 +50,36 @@ export class Shape {
|
|||
}
|
||||
|
||||
toFabric(size: Size): fabric.ForCloze {
|
||||
this.makeAbsolute(size);
|
||||
return new fabric.Object(this);
|
||||
const absolute = this.toAbsolute(size);
|
||||
return new fabric.Object(absolute);
|
||||
}
|
||||
|
||||
makeNormal(size: Size): void {
|
||||
this.left = xToNormalized(size, this.left);
|
||||
this.top = yToNormalized(size, this.top);
|
||||
normalPosition(size: Size) {
|
||||
return {
|
||||
left: xToNormalized(size, this.left),
|
||||
top: yToNormalized(size, this.top),
|
||||
};
|
||||
}
|
||||
|
||||
makeAbsolute(size: Size): void {
|
||||
this.left = xFromNormalized(size, this.left);
|
||||
this.top = yFromNormalized(size, this.top);
|
||||
toNormal(size: Size): Shape {
|
||||
return new Shape({
|
||||
...this,
|
||||
...this.normalPosition(size),
|
||||
});
|
||||
}
|
||||
|
||||
absolutePosition(size: Size) {
|
||||
return {
|
||||
left: xFromNormalized(size, this.left),
|
||||
top: yFromNormalized(size, this.top),
|
||||
};
|
||||
}
|
||||
|
||||
toAbsolute(size: Size): Shape {
|
||||
return new Shape({
|
||||
...this,
|
||||
...this.absolutePosition(size),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -28,20 +28,26 @@ export class Ellipse extends Shape {
|
|||
}
|
||||
|
||||
toFabric(size: Size): fabric.Ellipse {
|
||||
this.makeAbsolute(size);
|
||||
return new fabric.Ellipse(this);
|
||||
const absolute = this.toAbsolute(size);
|
||||
return new fabric.Ellipse(absolute);
|
||||
}
|
||||
|
||||
makeNormal(size: Size): void {
|
||||
super.makeNormal(size);
|
||||
this.rx = xToNormalized(size, this.rx);
|
||||
this.ry = yToNormalized(size, this.ry);
|
||||
toNormal(size: Size): Ellipse {
|
||||
return new Ellipse({
|
||||
...this,
|
||||
...super.normalPosition(size),
|
||||
rx: xToNormalized(size, this.rx),
|
||||
ry: yToNormalized(size, this.ry),
|
||||
});
|
||||
}
|
||||
|
||||
makeAbsolute(size: Size): void {
|
||||
super.makeAbsolute(size);
|
||||
this.rx = xFromNormalized(size, this.rx);
|
||||
this.ry = yFromNormalized(size, this.ry);
|
||||
toAbsolute(size: Size): Ellipse {
|
||||
return new Ellipse({
|
||||
...this,
|
||||
...super.absolutePosition(size),
|
||||
rx: xFromNormalized(size, this.rx),
|
||||
ry: yFromNormalized(size, this.ry),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -51,6 +51,7 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
|
|||
}
|
||||
const props = {
|
||||
occludeInactive: cloze.dataset.occludeinactive === "1",
|
||||
ordinal: parseInt(cloze.dataset.ordinal!),
|
||||
left: cloze.dataset.left,
|
||||
top: cloze.dataset.top,
|
||||
width: cloze.dataset.width,
|
||||
|
|
9
ts/image-occlusion/shapes/index.ts
Normal file
9
ts/image-occlusion/shapes/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
export { Shape } from "./base";
|
||||
export { Ellipse } from "./ellipse";
|
||||
export { extractShapesFromRenderedClozes } from "./from-cloze";
|
||||
export { Polygon } from "./polygon";
|
||||
export { Rectangle } from "./rectangle";
|
||||
export { Text } from "./text";
|
|
@ -25,23 +25,37 @@ export class Polygon extends Shape {
|
|||
}
|
||||
|
||||
toFabric(size: Size): fabric.Polygon {
|
||||
this.makeAbsolute(size);
|
||||
return new fabric.Polygon(this.points, this);
|
||||
const absolute = this.toAbsolute(size);
|
||||
return new fabric.Polygon(absolute.points, absolute);
|
||||
}
|
||||
|
||||
makeNormal(size: Size): void {
|
||||
super.makeNormal(size);
|
||||
toNormal(size: Size): Polygon {
|
||||
const points: Point[] = [];
|
||||
this.points.forEach((p) => {
|
||||
p.x = xToNormalized(size, p.x);
|
||||
p.y = yToNormalized(size, p.y);
|
||||
points.push({
|
||||
x: xToNormalized(size, p.x),
|
||||
y: yToNormalized(size, p.y),
|
||||
});
|
||||
});
|
||||
return new Polygon({
|
||||
...this,
|
||||
...super.normalPosition(size),
|
||||
points,
|
||||
});
|
||||
}
|
||||
|
||||
makeAbsolute(size: Size): void {
|
||||
super.makeAbsolute(size);
|
||||
toAbsolute(size: Size): Polygon {
|
||||
const points: Point[] = [];
|
||||
this.points.forEach((p) => {
|
||||
p.x = xFromNormalized(size, p.x);
|
||||
p.y = yFromNormalized(size, p.y);
|
||||
points.push({
|
||||
x: xFromNormalized(size, p.x),
|
||||
y: yFromNormalized(size, p.y),
|
||||
});
|
||||
});
|
||||
return new Polygon({
|
||||
...this,
|
||||
...super.absolutePosition(size),
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,20 +28,26 @@ export class Rectangle extends Shape {
|
|||
}
|
||||
|
||||
toFabric(size: Size): fabric.Rect {
|
||||
this.makeAbsolute(size);
|
||||
return new fabric.Rect(this);
|
||||
const absolute = this.toAbsolute(size);
|
||||
return new fabric.Rect(absolute);
|
||||
}
|
||||
|
||||
makeNormal(size: Size): void {
|
||||
super.makeNormal(size);
|
||||
this.width = xToNormalized(size, this.width);
|
||||
this.height = yToNormalized(size, this.height);
|
||||
toNormal(size: Size): Rectangle {
|
||||
return new Rectangle({
|
||||
...this,
|
||||
...super.normalPosition(size),
|
||||
width: xToNormalized(size, this.width),
|
||||
height: yToNormalized(size, this.height),
|
||||
});
|
||||
}
|
||||
|
||||
makeAbsolute(size: Size): void {
|
||||
super.makeAbsolute(size);
|
||||
this.width = xFromNormalized(size, this.width);
|
||||
this.height = yFromNormalized(size, this.height);
|
||||
toAbsolute(size: Size): Rectangle {
|
||||
return new Rectangle({
|
||||
...this,
|
||||
...super.absolutePosition(size),
|
||||
width: xFromNormalized(size, this.width),
|
||||
height: yFromNormalized(size, this.height),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,25 +37,31 @@ export class Text extends Shape {
|
|||
}
|
||||
|
||||
toFabric(size: Size): fabric.IText {
|
||||
this.makeAbsolute(size);
|
||||
return new fabric.IText(this.text, {
|
||||
...this,
|
||||
const absolute = this.toAbsolute(size);
|
||||
return new fabric.IText(absolute.text, {
|
||||
...absolute,
|
||||
fontFamily: TEXT_FONT_FAMILY,
|
||||
backgroundColor: TEXT_BACKGROUND_COLOR,
|
||||
padding: TEXT_PADDING,
|
||||
});
|
||||
}
|
||||
|
||||
makeNormal(size: Size): void {
|
||||
super.makeNormal(size);
|
||||
this.scaleX = xToNormalized(size, this.scaleX);
|
||||
this.scaleY = yToNormalized(size, this.scaleY);
|
||||
toNormal(size: Size): Text {
|
||||
return new Text({
|
||||
...this,
|
||||
...super.normalPosition(size),
|
||||
scaleX: xToNormalized(size, this.scaleX),
|
||||
scaleY: yToNormalized(size, this.scaleY),
|
||||
});
|
||||
}
|
||||
|
||||
makeAbsolute(size: Size): void {
|
||||
super.makeAbsolute(size);
|
||||
this.scaleX = xFromNormalized(size, this.scaleX);
|
||||
this.scaleY = yFromNormalized(size, this.scaleY);
|
||||
toAbsolute(size: Size): Text {
|
||||
return new Text({
|
||||
...this,
|
||||
...super.absolutePosition(size),
|
||||
scaleX: xFromNormalized(size, this.scaleX),
|
||||
scaleY: yFromNormalized(size, this.scaleY),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ function fabricObjectToBaseShapeOrShapes(
|
|||
shape.top = newPosition.y;
|
||||
}
|
||||
|
||||
shape.makeNormal(size);
|
||||
shape = shape.toNormal(size);
|
||||
return shape;
|
||||
}
|
||||
|
||||
|
@ -151,6 +151,7 @@ function shapeOrShapesToCloze(
|
|||
} else {
|
||||
ordinal = index + 1;
|
||||
}
|
||||
shapeOrShapes.ordinal = ordinal;
|
||||
text = `{{c${ordinal}::image-occlusion:${type}${text}}}<br>`;
|
||||
|
||||
return text;
|
||||
|
|
|
@ -9,12 +9,13 @@ import "css-browser-selector/css_browser_selector.min";
|
|||
|
||||
export { default as $, default as jQuery } from "jquery/dist/jquery";
|
||||
|
||||
import { setupImageCloze } from "../image-occlusion/review";
|
||||
import { imageOcclusionAPI } from "../image-occlusion/review";
|
||||
import { mutateNextCardStates } from "./answering";
|
||||
|
||||
globalThis.anki = globalThis.anki || {};
|
||||
globalThis.anki.mutateNextCardStates = mutateNextCardStates;
|
||||
globalThis.anki.setupImageCloze = setupImageCloze;
|
||||
globalThis.anki.imageOcclusion = imageOcclusionAPI;
|
||||
globalThis.anki.setupImageCloze = imageOcclusionAPI.setup; // deprecated
|
||||
|
||||
import { bridgeCommand } from "@tslib/bridgecommand";
|
||||
import { registerPackage } from "@tslib/runtime-require";
|
||||
|
|
|
@ -5,13 +5,14 @@
|
|||
@typescript-eslint/no-explicit-any: "off",
|
||||
*/
|
||||
|
||||
// A standalone bundle that adds mutateNextCardStates and setupImageCloze
|
||||
// A standalone bundle that adds mutateNextCardStates and the image occlusion API
|
||||
// to the anki namespace. When all clients are using reviewer.js directly, we
|
||||
// can get rid of this.
|
||||
|
||||
import { setupImageCloze } from "../image-occlusion/review";
|
||||
import { imageOcclusionAPI } from "../image-occlusion/review";
|
||||
import { mutateNextCardStates } from "./answering";
|
||||
|
||||
globalThis.anki = globalThis.anki || {};
|
||||
globalThis.anki.mutateNextCardStates = mutateNextCardStates;
|
||||
globalThis.anki.setupImageCloze = setupImageCloze;
|
||||
globalThis.anki.imageOcclusion = imageOcclusionAPI;
|
||||
globalThis.anki.setupImageCloze = imageOcclusionAPI.setup; // deprecated
|
||||
|
|
Loading…
Reference in a new issue