From 6d767c4fffeedd9efead08029465138ac880eba7 Mon Sep 17 00:00:00 2001 From: llama <100429699+iamllama@users.noreply.github.com> Date: Thu, 8 May 2025 00:28:07 +0800 Subject: [PATCH 1/9] Revert "Disable rotation globally" This reverts commit 22736238c1ef16139d9c1ff87361c1354a40cc2c. --- ts/routes/image-occlusion/mask-editor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/ts/routes/image-occlusion/mask-editor.ts b/ts/routes/image-occlusion/mask-editor.ts index 251df2503d9..b662f462c58 100644 --- a/ts/routes/image-occlusion/mask-editor.ts +++ b/ts/routes/image-occlusion/mask-editor.ts @@ -99,8 +99,6 @@ function initCanvas(): fabric.Canvas { // Disable uniform scaling canvas.uniformScaling = false; canvas.uniScaleKey = "none"; - // disable rotation globally - delete fabric.Object.prototype.controls.mtr; // disable object caching fabric.Object.prototype.objectCaching = false; // add a border to corner to handle blend of control From 3f3dda826debd3ca50ab25d230da991638a5d212 Mon Sep 17 00:00:00 2001 From: llama <100429699+iamllama@users.noreply.github.com> Date: Thu, 8 May 2025 00:29:00 +0800 Subject: [PATCH 2/9] alt. impl for hiding rotation marker when selecting/ungrouping --- ts/routes/image-occlusion/mask-editor.ts | 5 +++++ ts/routes/image-occlusion/tools/lib.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ts/routes/image-occlusion/mask-editor.ts b/ts/routes/image-occlusion/mask-editor.ts index b662f462c58..fca8453a64f 100644 --- a/ts/routes/image-occlusion/mask-editor.ts +++ b/ts/routes/image-occlusion/mask-editor.ts @@ -106,6 +106,11 @@ function initCanvas(): fabric.Canvas { fabric.Object.prototype.cornerStyle = "circle"; fabric.Object.prototype.cornerStrokeColor = "#000000"; fabric.Object.prototype.padding = 8; + // disable rotation when selecting + canvas.on("selection:created", () => { + const g = canvas.getActiveObject(); + if (g && g instanceof fabric.Group) { g.setControlsVisibility({ mtr: false }); } + }); canvas.on("object:modified", (evt) => { if (evt.target instanceof fabric.Polygon) { modifiedPolygon(canvas, evt.target); diff --git a/ts/routes/image-occlusion/tools/lib.ts b/ts/routes/image-occlusion/tools/lib.ts index d00de6326e0..c352899e010 100644 --- a/ts/routes/image-occlusion/tools/lib.ts +++ b/ts/routes/image-occlusion/tools/lib.ts @@ -76,7 +76,7 @@ export const groupShapes = (canvas: fabric.Canvas): void => { activeObject.toGroup().set({ opacity: get(opacityStateStore) ? 0.4 : 1, - }); + }).setControlsVisibility({ mtr: false }); redraw(canvas); }; From c710a4f053432208a466dba05c5ed61972cfe335 Mon Sep 17 00:00:00 2001 From: llama <100429699+iamllama@users.noreply.github.com> Date: Thu, 8 May 2025 00:31:55 +0800 Subject: [PATCH 3/9] (de)serialise angles --- rslib/src/image_occlusion/imageocclusion.rs | 5 +++++ ts/routes/image-occlusion/shapes/base.ts | 8 ++++++-- ts/routes/image-occlusion/shapes/from-cloze.ts | 4 ++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs index 8d51fae5bfc..0658b4319e6 100644 --- a/rslib/src/image_occlusion/imageocclusion.rs +++ b/rslib/src/image_occlusion/imageocclusion.rs @@ -74,6 +74,11 @@ pub fn get_image_cloze_data(text: &str) -> String { result.push_str(&format!("data-top=\"{}\" ", property.value)); } } + "angle" => { + if !property.value.is_empty() { + result.push_str(&format!("data-angle=\"{}\" ", property.value)); + } + } "width" => { if !is_empty_or_zero(&property.value) { result.push_str(&format!("data-width=\"{}\" ", property.value)); diff --git a/ts/routes/image-occlusion/shapes/base.ts b/ts/routes/image-occlusion/shapes/base.ts index 95ea7407c1e..7d5ab27ea4e 100644 --- a/ts/routes/image-occlusion/shapes/base.ts +++ b/ts/routes/image-occlusion/shapes/base.ts @@ -18,6 +18,7 @@ export type ShapeOrShapes = Shape | Shape[]; export class Shape { left: number; top: number; + angle?: number; // polygons don't use it fill: string; /** Whether occlusions from other cloze numbers should be shown on the * question side. Used only in reviewer code. @@ -27,11 +28,12 @@ export class Shape { ordinal: number | undefined; constructor( - { left = 0, top = 0, fill = SHAPE_MASK_COLOR, occludeInactive, ordinal = undefined }: ConstructorParams = - {}, + { left = 0, top = 0, angle = 0, fill = SHAPE_MASK_COLOR, occludeInactive, ordinal = undefined }: + ConstructorParams = {}, ) { this.left = left; this.top = top; + this.angle = angle; this.fill = fill; this.occludeInactive = occludeInactive; this.ordinal = ordinal; @@ -44,6 +46,7 @@ export class Shape { return { left: floatToDisplay(this.left), top: floatToDisplay(this.top), + ...(!this.angle ? {} : { angle: floatToDisplay(this.angle) }), ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }), }; } @@ -85,6 +88,7 @@ export class Shape { export interface ShapeDataForCloze { left: string; top: string; + angle?: string; fill?: string; oi?: string; } diff --git a/ts/routes/image-occlusion/shapes/from-cloze.ts b/ts/routes/image-occlusion/shapes/from-cloze.ts index 71c0e91a8a7..f356ca43abb 100644 --- a/ts/routes/image-occlusion/shapes/from-cloze.ts +++ b/ts/routes/image-occlusion/shapes/from-cloze.ts @@ -75,6 +75,7 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null { text: cloze.dataset.text, scale: cloze.dataset.scale, fs: cloze.dataset.fontSize, + angle: cloze.dataset.angle, }; return buildShape(type, props); } @@ -92,6 +93,9 @@ function buildShape(type: ShapeType, props: Record): Shape { props.top = parseFloat( Number.isNaN(Number(props.top)) ? ".0000" : props.top, ); + props.angle = parseFloat( + Number.isNaN(Number(props.angle)) ? "0" : props.angle, + ); switch (type) { case "rect": { return new Rectangle({ From 5064a40941be8a3c7e45cc64258412ab0088eb9e Mon Sep 17 00:00:00 2001 From: llama <100429699+iamllama@users.noreply.github.com> Date: Thu, 8 May 2025 00:32:55 +0800 Subject: [PATCH 4/9] rotate masks in reviewer --- ts/routes/image-occlusion/review.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/ts/routes/image-occlusion/review.ts b/ts/routes/image-occlusion/review.ts index 84fb3babcad..6ddc5e3f2b8 100644 --- a/ts/routes/image-occlusion/review.ts +++ b/ts/routes/image-occlusion/review.ts @@ -263,15 +263,29 @@ function drawShape({ ctx.fillStyle = fill; ctx.strokeStyle = stroke; ctx.lineWidth = strokeWidth; + const angle = ((shape.angle ?? 0) * Math.PI) / 180; if (shape instanceof Rectangle) { + if (angle) { + ctx.save(); + ctx.translate(shape.left, shape.top); + ctx.rotate(angle); + ctx.translate(-shape.left, -shape.top); + } ctx.fillRect(shape.left, shape.top, shape.width, shape.height); // ctx stroke methods will draw a visible stroke, even if the width is 0 if (strokeWidth) { ctx.strokeRect(shape.left, shape.top, shape.width, shape.height); } + if (angle) { ctx.restore(); } } else if (shape instanceof Ellipse) { const adjustedLeft = shape.left + shape.rx; const adjustedTop = shape.top + shape.ry; + if (angle) { + ctx.save(); + ctx.translate(shape.left, shape.top); + ctx.rotate(angle); + ctx.translate(-shape.left, -shape.top); + } ctx.beginPath(); ctx.ellipse( adjustedLeft, @@ -288,6 +302,7 @@ function drawShape({ if (strokeWidth) { ctx.stroke(); } + if (angle) { ctx.restore(); } } else if (shape instanceof Polygon) { const offset = getPolygonOffset(shape); ctx.save(); @@ -329,10 +344,17 @@ function drawShape({ } totalHeight += lineHeight; } + const left = shape.left / shape.scaleX; + const top = shape.top / shape.scaleY; + if (angle) { + ctx.translate(left, top); + ctx.rotate(angle); + ctx.translate(-left, -top); + } ctx.fillStyle = TEXT_BACKGROUND_COLOR; ctx.fillRect( - shape.left / shape.scaleX, - shape.top / shape.scaleY, + left, + top, maxWidth + TEXT_PADDING, totalHeight + TEXT_PADDING, ); From 899f79054a858f2e8462d103c7ddc1130b7a1897 Mon Sep 17 00:00:00 2001 From: llama <100429699+iamllama@users.noreply.github.com> Date: Thu, 8 May 2025 00:39:00 +0800 Subject: [PATCH 5/9] update bounds checking --- ts/routes/image-occlusion/tools/lib.ts | 37 +++++++++++++++----------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/ts/routes/image-occlusion/tools/lib.ts b/ts/routes/image-occlusion/tools/lib.ts index c352899e010..35120e61ca1 100644 --- a/ts/routes/image-occlusion/tools/lib.ts +++ b/ts/routes/image-occlusion/tools/lib.ts @@ -228,18 +228,21 @@ const setShapePosition = ( boundingBox: fabric.Rect, object: fabric.Object, ): void => { - if (object.left! < 0) { - object.set({ left: 0 }); + const { left, top, width, height } = object.getBoundingRect(true); + + if (left < 0) { + object.set({ left: Math.max(object.left! - left, 0) }); } - if (object.top! < 0) { - object.set({ top: 0 }); + if (top < 0) { + object.set({ top: Math.max(object.top! - top, 0) }); } - if (object.left! + object.width! * object.scaleX! + object.strokeWidth! > boundingBox.width!) { - object.set({ left: boundingBox.width! - object.width! * object.scaleX! }); + if (left > boundingBox.width!) { + object.set({ left: object.left! - left - width + boundingBox.width! }); } - if (object.top! + object.height! * object.scaleY! + object.strokeWidth! > boundingBox.height!) { - object.set({ top: boundingBox.height! - object.height! * object.scaleY! }); + if (top > boundingBox.height!) { + object.set({ top: object.top! - top - height + boundingBox.height! }); } + object.setCoords(); }; @@ -277,23 +280,25 @@ export const makeShapesRemainInCanvas = (canvas: fabric.Canvas, boundingBox: fab canvas.on("object:moving", function(e) { const obj = e.target!; - const objWidth = obj.getScaledWidth(); - const objHeight = obj.getScaledHeight(); + const { left: objBbLeft, top: objBbTop, width: objBbWidth, height: objBbHeight } = obj.getBoundingRect( + true, + true, + ); - if (objWidth > boundingBox.width! || objHeight > boundingBox.height!) { + if (objBbWidth > boundingBox.width! || objBbHeight > boundingBox.height!) { return; } - const top = obj.top!; - const left = obj.left!; - const topBound = boundingBox.top!; const bottomBound = topBound + boundingBox.height! + 5; const leftBound = boundingBox.left!; const rightBound = leftBound + boundingBox.width! + 5; - obj.left = Math.min(Math.max(left, leftBound), rightBound - objWidth); - obj.top = Math.min(Math.max(top, topBound), bottomBound - objHeight); + const newBbLeft = Math.min(Math.max(objBbLeft, leftBound), rightBound - objBbWidth); + const newBbTop = Math.min(Math.max(objBbTop, topBound), bottomBound - objBbHeight); + + obj.left = obj.left! + newBbLeft - objBbLeft; + obj.top = obj.top! + newBbTop - objBbTop; }); }; From 1e494334fb609b5cd5e1c056f9304d0e26281c42 Mon Sep 17 00:00:00 2001 From: llama Date: Fri, 9 May 2025 11:04:39 +0800 Subject: [PATCH 6/9] floats.ts -> lib.ts --- ts/routes/image-occlusion/shapes/base.ts | 2 +- ts/routes/image-occlusion/shapes/ellipse.ts | 2 +- ts/routes/image-occlusion/shapes/{floats.ts => lib.ts} | 0 ts/routes/image-occlusion/shapes/polygon.ts | 2 +- ts/routes/image-occlusion/shapes/rectangle.ts | 2 +- ts/routes/image-occlusion/shapes/text.ts | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename ts/routes/image-occlusion/shapes/{floats.ts => lib.ts} (100%) diff --git a/ts/routes/image-occlusion/shapes/base.ts b/ts/routes/image-occlusion/shapes/base.ts index 7d5ab27ea4e..9e20a8062fb 100644 --- a/ts/routes/image-occlusion/shapes/base.ts +++ b/ts/routes/image-occlusion/shapes/base.ts @@ -5,7 +5,7 @@ import { fabric } from "fabric"; import { SHAPE_MASK_COLOR } from "../tools/lib"; import type { ConstructorParams, Size } from "../types"; -import { floatToDisplay } from "./floats"; +import { floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export type ShapeOrShapes = Shape | Shape[]; diff --git a/ts/routes/image-occlusion/shapes/ellipse.ts b/ts/routes/image-occlusion/shapes/ellipse.ts index bd2d81baf05..1bbeac9c86e 100644 --- a/ts/routes/image-occlusion/shapes/ellipse.ts +++ b/ts/routes/image-occlusion/shapes/ellipse.ts @@ -6,7 +6,7 @@ import { fabric } from "fabric"; import type { ConstructorParams, Size } from "../types"; import type { ShapeDataForCloze } from "./base"; import { Shape } from "./base"; -import { floatToDisplay } from "./floats"; +import { floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export class Ellipse extends Shape { diff --git a/ts/routes/image-occlusion/shapes/floats.ts b/ts/routes/image-occlusion/shapes/lib.ts similarity index 100% rename from ts/routes/image-occlusion/shapes/floats.ts rename to ts/routes/image-occlusion/shapes/lib.ts diff --git a/ts/routes/image-occlusion/shapes/polygon.ts b/ts/routes/image-occlusion/shapes/polygon.ts index 23c5ead751d..9e967d0a697 100644 --- a/ts/routes/image-occlusion/shapes/polygon.ts +++ b/ts/routes/image-occlusion/shapes/polygon.ts @@ -6,7 +6,7 @@ import { fabric } from "fabric"; import type { ConstructorParams, Size } from "../types"; import type { ShapeDataForCloze } from "./base"; import { Shape } from "./base"; -import { floatToDisplay } from "./floats"; +import { floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export class Polygon extends Shape { diff --git a/ts/routes/image-occlusion/shapes/rectangle.ts b/ts/routes/image-occlusion/shapes/rectangle.ts index df061fe455a..2e0b72f5431 100644 --- a/ts/routes/image-occlusion/shapes/rectangle.ts +++ b/ts/routes/image-occlusion/shapes/rectangle.ts @@ -6,7 +6,7 @@ import { fabric } from "fabric"; import type { ConstructorParams, Size } from "../types"; import type { ShapeDataForCloze } from "./base"; import { Shape } from "./base"; -import { floatToDisplay } from "./floats"; +import { floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export class Rectangle extends Shape { diff --git a/ts/routes/image-occlusion/shapes/text.ts b/ts/routes/image-occlusion/shapes/text.ts index fd7ac191e9a..604edf3e327 100644 --- a/ts/routes/image-occlusion/shapes/text.ts +++ b/ts/routes/image-occlusion/shapes/text.ts @@ -7,7 +7,7 @@ import { TEXT_BACKGROUND_COLOR, TEXT_COLOR, TEXT_FONT_FAMILY, TEXT_FONT_SIZE, TE import type { ConstructorParams, Size } from "../types"; import type { ShapeDataForCloze } from "./base"; import { Shape } from "./base"; -import { floatToDisplay } from "./floats"; +import { floatToDisplay } from "./lib"; export class Text extends Shape { text: string; From 122328eeaba84dfd48ad451ae3b12e6eb8cf9eb6 Mon Sep 17 00:00:00 2001 From: llama Date: Fri, 9 May 2025 11:05:16 +0800 Subject: [PATCH 7/9] add convenience fns --- ts/routes/image-occlusion/shapes/lib.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ts/routes/image-occlusion/shapes/lib.ts b/ts/routes/image-occlusion/shapes/lib.ts index 4521a1f1b49..51e00332ece 100644 --- a/ts/routes/image-occlusion/shapes/lib.ts +++ b/ts/routes/image-occlusion/shapes/lib.ts @@ -11,3 +11,15 @@ export function floatToDisplay(number: number): string { } return number.toFixed(4).replace(/^0+|0+$/g, ""); } + +const ANGLE_STEPS = 10000; + +export function angleToStored(angle: any): number | null { + const angleDeg = Number(angle) % 360; + return Number.isNaN(angleDeg) ? null : Math.round((angleDeg / 360) * ANGLE_STEPS); +} + +export function storedToAngle(x: any): number | null { + const angleSteps = Number(x) % ANGLE_STEPS; + return Number.isNaN(angleSteps) ? null : (angleSteps / ANGLE_STEPS) * 360; +} From 5d4c63a1fb697eb7d34659b0ebc599b157d18ea0 Mon Sep 17 00:00:00 2001 From: llama Date: Fri, 9 May 2025 11:07:00 +0800 Subject: [PATCH 8/9] store mask angles (deg) in steps of 10000 --- ts/routes/image-occlusion/shapes/base.ts | 5 +++-- ts/routes/image-occlusion/shapes/from-cloze.ts | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ts/routes/image-occlusion/shapes/base.ts b/ts/routes/image-occlusion/shapes/base.ts index 9e20a8062fb..c0db4873016 100644 --- a/ts/routes/image-occlusion/shapes/base.ts +++ b/ts/routes/image-occlusion/shapes/base.ts @@ -5,7 +5,7 @@ import { fabric } from "fabric"; import { SHAPE_MASK_COLOR } from "../tools/lib"; import type { ConstructorParams, Size } from "../types"; -import { floatToDisplay } from "./lib"; +import { angleToStored, floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export type ShapeOrShapes = Shape | Shape[]; @@ -43,10 +43,11 @@ export class Shape { * text. */ toDataForCloze(): ShapeDataForCloze { + const angle = angleToStored(this.angle); return { left: floatToDisplay(this.left), top: floatToDisplay(this.top), - ...(!this.angle ? {} : { angle: floatToDisplay(this.angle) }), + ...(!angle ? {} : { angle: angle.toString() }), ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }), }; } diff --git a/ts/routes/image-occlusion/shapes/from-cloze.ts b/ts/routes/image-occlusion/shapes/from-cloze.ts index f356ca43abb..a59f8f965de 100644 --- a/ts/routes/image-occlusion/shapes/from-cloze.ts +++ b/ts/routes/image-occlusion/shapes/from-cloze.ts @@ -9,6 +9,7 @@ import type { GetImageOcclusionNoteResponse_ImageOcclusion } from "@generated/an import type { Shape, ShapeOrShapes } from "./base"; import { Ellipse } from "./ellipse"; +import { storedToAngle } from "./lib"; import { Point, Polygon } from "./polygon"; import { Rectangle } from "./rectangle"; import { Text } from "./text"; @@ -93,9 +94,7 @@ function buildShape(type: ShapeType, props: Record): Shape { props.top = parseFloat( Number.isNaN(Number(props.top)) ? ".0000" : props.top, ); - props.angle = parseFloat( - Number.isNaN(Number(props.angle)) ? "0" : props.angle, - ); + props.angle = storedToAngle(props.angle) ?? 0; switch (type) { case "rect": { return new Rectangle({ From 4fbf6461d615756a6d8499d431db830b6301d130 Mon Sep 17 00:00:00 2001 From: llama Date: Fri, 9 May 2025 11:09:50 +0800 Subject: [PATCH 9/9] update CONTRIBUTORS --- CONTRIBUTORS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 491492b00a1..53cb47b2968 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -201,7 +201,7 @@ Dongjin Ouyang <1113117424@qq.com> Sawan Sunar hideo aoyama Ross Brown -🦙 +🦙 Lukas Sommer Luca Auer Niclas Heinz