From 573f59fab151b2afbea03a24ce34c21727d1b99b Mon Sep 17 00:00:00 2001 From: llama Date: Sat, 10 May 2025 14:21:33 +0800 Subject: [PATCH] Allow rotating IO masks (#3987) * Revert "Disable rotation globally" This reverts commit 22736238c1ef16139d9c1ff87361c1354a40cc2c. * alt. impl for hiding rotation marker when selecting/ungrouping * (de)serialise angles * rotate masks in reviewer * update bounds checking * floats.ts -> lib.ts * add convenience fns * store mask angles (deg) in steps of 10000 * update CONTRIBUTORS --- CONTRIBUTORS | 2 +- rslib/src/image_occlusion/imageocclusion.rs | 5 +++ ts/routes/image-occlusion/mask-editor.ts | 7 +++- ts/routes/image-occlusion/review.ts | 26 ++++++++++++- ts/routes/image-occlusion/shapes/base.ts | 11 ++++-- ts/routes/image-occlusion/shapes/ellipse.ts | 2 +- .../image-occlusion/shapes/from-cloze.ts | 3 ++ .../shapes/{floats.ts => lib.ts} | 12 ++++++ ts/routes/image-occlusion/shapes/polygon.ts | 2 +- ts/routes/image-occlusion/shapes/rectangle.ts | 2 +- ts/routes/image-occlusion/shapes/text.ts | 2 +- ts/routes/image-occlusion/tools/lib.ts | 39 +++++++++++-------- 12 files changed, 84 insertions(+), 29 deletions(-) rename ts/routes/image-occlusion/shapes/{floats.ts => lib.ts} (54%) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 491492b00..53cb47b29 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 diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs index 8d51fae5b..0658b4319 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/mask-editor.ts b/ts/routes/image-occlusion/mask-editor.ts index 70918dee0..fb29977cd 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 @@ -108,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/review.ts b/ts/routes/image-occlusion/review.ts index 84fb3babc..6ddc5e3f2 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, ); diff --git a/ts/routes/image-occlusion/shapes/base.ts b/ts/routes/image-occlusion/shapes/base.ts index 95ea7407c..c0db48730 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 { angleToStored, floatToDisplay } from "./lib"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; export type ShapeOrShapes = Shape | Shape[]; @@ -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; @@ -41,9 +43,11 @@ export class Shape { * text. */ toDataForCloze(): ShapeDataForCloze { + const angle = angleToStored(this.angle); return { left: floatToDisplay(this.left), top: floatToDisplay(this.top), + ...(!angle ? {} : { angle: angle.toString() }), ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }), }; } @@ -85,6 +89,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/ellipse.ts b/ts/routes/image-occlusion/shapes/ellipse.ts index bd2d81baf..1bbeac9c8 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/from-cloze.ts b/ts/routes/image-occlusion/shapes/from-cloze.ts index 71c0e91a8..a59f8f965 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"; @@ -75,6 +76,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 +94,7 @@ function buildShape(type: ShapeType, props: Record): Shape { props.top = parseFloat( Number.isNaN(Number(props.top)) ? ".0000" : props.top, ); + props.angle = storedToAngle(props.angle) ?? 0; switch (type) { case "rect": { return new Rectangle({ diff --git a/ts/routes/image-occlusion/shapes/floats.ts b/ts/routes/image-occlusion/shapes/lib.ts similarity index 54% rename from ts/routes/image-occlusion/shapes/floats.ts rename to ts/routes/image-occlusion/shapes/lib.ts index 4521a1f1b..51e00332e 100644 --- a/ts/routes/image-occlusion/shapes/floats.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; +} diff --git a/ts/routes/image-occlusion/shapes/polygon.ts b/ts/routes/image-occlusion/shapes/polygon.ts index 23c5ead75..9e967d0a6 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 df061fe45..2e0b72f54 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 fd7ac191e..604edf3e3 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; diff --git a/ts/routes/image-occlusion/tools/lib.ts b/ts/routes/image-occlusion/tools/lib.ts index d00de6326..35120e61c 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); }; @@ -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; }); };