Allow rotating IO masks (#3987)

* Revert "Disable rotation globally"

This reverts commit 22736238c1.

* 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
This commit is contained in:
llama 2025-05-10 14:21:33 +08:00 committed by GitHub
parent 5cc44b3f68
commit 573f59fab1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 84 additions and 29 deletions

View file

@ -201,7 +201,7 @@ Dongjin Ouyang <1113117424@qq.com>
Sawan Sunar <sawansunar24072002@gmail.com> Sawan Sunar <sawansunar24072002@gmail.com>
hideo aoyama <https://github.com/boukendesho> hideo aoyama <https://github.com/boukendesho>
Ross Brown <rbrownwsws@googlemail.com> Ross Brown <rbrownwsws@googlemail.com>
🦙 <github.com/iamllama> 🦙 <gh@siid.sh>
Lukas Sommer <sommerluk@gmail.com> Lukas Sommer <sommerluk@gmail.com>
Luca Auer <lolle2000.la@gmail.com> Luca Auer <lolle2000.la@gmail.com>
Niclas Heinz <nheinz@hpost.net> Niclas Heinz <nheinz@hpost.net>

View file

@ -74,6 +74,11 @@ pub fn get_image_cloze_data(text: &str) -> String {
result.push_str(&format!("data-top=\"{}\" ", property.value)); result.push_str(&format!("data-top=\"{}\" ", property.value));
} }
} }
"angle" => {
if !property.value.is_empty() {
result.push_str(&format!("data-angle=\"{}\" ", property.value));
}
}
"width" => { "width" => {
if !is_empty_or_zero(&property.value) { if !is_empty_or_zero(&property.value) {
result.push_str(&format!("data-width=\"{}\" ", property.value)); result.push_str(&format!("data-width=\"{}\" ", property.value));

View file

@ -99,8 +99,6 @@ function initCanvas(): fabric.Canvas {
// Disable uniform scaling // Disable uniform scaling
canvas.uniformScaling = false; canvas.uniformScaling = false;
canvas.uniScaleKey = "none"; canvas.uniScaleKey = "none";
// disable rotation globally
delete fabric.Object.prototype.controls.mtr;
// disable object caching // disable object caching
fabric.Object.prototype.objectCaching = false; fabric.Object.prototype.objectCaching = false;
// add a border to corner to handle blend of control // 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.cornerStyle = "circle";
fabric.Object.prototype.cornerStrokeColor = "#000000"; fabric.Object.prototype.cornerStrokeColor = "#000000";
fabric.Object.prototype.padding = 8; 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) => { canvas.on("object:modified", (evt) => {
if (evt.target instanceof fabric.Polygon) { if (evt.target instanceof fabric.Polygon) {
modifiedPolygon(canvas, evt.target); modifiedPolygon(canvas, evt.target);

View file

@ -263,15 +263,29 @@ function drawShape({
ctx.fillStyle = fill; ctx.fillStyle = fill;
ctx.strokeStyle = stroke; ctx.strokeStyle = stroke;
ctx.lineWidth = strokeWidth; ctx.lineWidth = strokeWidth;
const angle = ((shape.angle ?? 0) * Math.PI) / 180;
if (shape instanceof Rectangle) { 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.fillRect(shape.left, shape.top, shape.width, shape.height);
// ctx stroke methods will draw a visible stroke, even if the width is 0 // ctx stroke methods will draw a visible stroke, even if the width is 0
if (strokeWidth) { if (strokeWidth) {
ctx.strokeRect(shape.left, shape.top, shape.width, shape.height); ctx.strokeRect(shape.left, shape.top, shape.width, shape.height);
} }
if (angle) { ctx.restore(); }
} else if (shape instanceof Ellipse) { } else if (shape instanceof Ellipse) {
const adjustedLeft = shape.left + shape.rx; const adjustedLeft = shape.left + shape.rx;
const adjustedTop = shape.top + shape.ry; 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.beginPath();
ctx.ellipse( ctx.ellipse(
adjustedLeft, adjustedLeft,
@ -288,6 +302,7 @@ function drawShape({
if (strokeWidth) { if (strokeWidth) {
ctx.stroke(); ctx.stroke();
} }
if (angle) { ctx.restore(); }
} else if (shape instanceof Polygon) { } else if (shape instanceof Polygon) {
const offset = getPolygonOffset(shape); const offset = getPolygonOffset(shape);
ctx.save(); ctx.save();
@ -329,10 +344,17 @@ function drawShape({
} }
totalHeight += lineHeight; 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.fillStyle = TEXT_BACKGROUND_COLOR;
ctx.fillRect( ctx.fillRect(
shape.left / shape.scaleX, left,
shape.top / shape.scaleY, top,
maxWidth + TEXT_PADDING, maxWidth + TEXT_PADDING,
totalHeight + TEXT_PADDING, totalHeight + TEXT_PADDING,
); );

View file

@ -5,7 +5,7 @@ import { fabric } from "fabric";
import { SHAPE_MASK_COLOR } from "../tools/lib"; import { SHAPE_MASK_COLOR } from "../tools/lib";
import type { ConstructorParams, Size } from "../types"; import type { ConstructorParams, Size } from "../types";
import { floatToDisplay } from "./floats"; import { angleToStored, floatToDisplay } from "./lib";
import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position";
export type ShapeOrShapes = Shape | Shape[]; export type ShapeOrShapes = Shape | Shape[];
@ -18,6 +18,7 @@ export type ShapeOrShapes = Shape | Shape[];
export class Shape { export class Shape {
left: number; left: number;
top: number; top: number;
angle?: number; // polygons don't use it
fill: string; fill: string;
/** Whether occlusions from other cloze numbers should be shown on the /** Whether occlusions from other cloze numbers should be shown on the
* question side. Used only in reviewer code. * question side. Used only in reviewer code.
@ -27,11 +28,12 @@ export class Shape {
ordinal: number | undefined; ordinal: number | undefined;
constructor( constructor(
{ left = 0, top = 0, fill = SHAPE_MASK_COLOR, occludeInactive, ordinal = undefined }: ConstructorParams<Shape> = { left = 0, top = 0, angle = 0, fill = SHAPE_MASK_COLOR, occludeInactive, ordinal = undefined }:
{}, ConstructorParams<Shape> = {},
) { ) {
this.left = left; this.left = left;
this.top = top; this.top = top;
this.angle = angle;
this.fill = fill; this.fill = fill;
this.occludeInactive = occludeInactive; this.occludeInactive = occludeInactive;
this.ordinal = ordinal; this.ordinal = ordinal;
@ -41,9 +43,11 @@ export class Shape {
* text. * text.
*/ */
toDataForCloze(): ShapeDataForCloze { toDataForCloze(): ShapeDataForCloze {
const angle = angleToStored(this.angle);
return { return {
left: floatToDisplay(this.left), left: floatToDisplay(this.left),
top: floatToDisplay(this.top), top: floatToDisplay(this.top),
...(!angle ? {} : { angle: angle.toString() }),
...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }), ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }),
}; };
} }
@ -85,6 +89,7 @@ export class Shape {
export interface ShapeDataForCloze { export interface ShapeDataForCloze {
left: string; left: string;
top: string; top: string;
angle?: string;
fill?: string; fill?: string;
oi?: string; oi?: string;
} }

View file

@ -6,7 +6,7 @@ import { fabric } from "fabric";
import type { ConstructorParams, Size } from "../types"; import type { ConstructorParams, Size } from "../types";
import type { ShapeDataForCloze } from "./base"; import type { ShapeDataForCloze } from "./base";
import { Shape } from "./base"; import { Shape } from "./base";
import { floatToDisplay } from "./floats"; import { floatToDisplay } from "./lib";
import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position";
export class Ellipse extends Shape { export class Ellipse extends Shape {

View file

@ -9,6 +9,7 @@ import type { GetImageOcclusionNoteResponse_ImageOcclusion } from "@generated/an
import type { Shape, ShapeOrShapes } from "./base"; import type { Shape, ShapeOrShapes } from "./base";
import { Ellipse } from "./ellipse"; import { Ellipse } from "./ellipse";
import { storedToAngle } from "./lib";
import { Point, Polygon } from "./polygon"; import { Point, Polygon } from "./polygon";
import { Rectangle } from "./rectangle"; import { Rectangle } from "./rectangle";
import { Text } from "./text"; import { Text } from "./text";
@ -75,6 +76,7 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
text: cloze.dataset.text, text: cloze.dataset.text,
scale: cloze.dataset.scale, scale: cloze.dataset.scale,
fs: cloze.dataset.fontSize, fs: cloze.dataset.fontSize,
angle: cloze.dataset.angle,
}; };
return buildShape(type, props); return buildShape(type, props);
} }
@ -92,6 +94,7 @@ function buildShape(type: ShapeType, props: Record<string, any>): Shape {
props.top = parseFloat( props.top = parseFloat(
Number.isNaN(Number(props.top)) ? ".0000" : props.top, Number.isNaN(Number(props.top)) ? ".0000" : props.top,
); );
props.angle = storedToAngle(props.angle) ?? 0;
switch (type) { switch (type) {
case "rect": { case "rect": {
return new Rectangle({ return new Rectangle({

View file

@ -11,3 +11,15 @@ export function floatToDisplay(number: number): string {
} }
return number.toFixed(4).replace(/^0+|0+$/g, ""); 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;
}

View file

@ -6,7 +6,7 @@ import { fabric } from "fabric";
import type { ConstructorParams, Size } from "../types"; import type { ConstructorParams, Size } from "../types";
import type { ShapeDataForCloze } from "./base"; import type { ShapeDataForCloze } from "./base";
import { Shape } from "./base"; import { Shape } from "./base";
import { floatToDisplay } from "./floats"; import { floatToDisplay } from "./lib";
import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position";
export class Polygon extends Shape { export class Polygon extends Shape {

View file

@ -6,7 +6,7 @@ import { fabric } from "fabric";
import type { ConstructorParams, Size } from "../types"; import type { ConstructorParams, Size } from "../types";
import type { ShapeDataForCloze } from "./base"; import type { ShapeDataForCloze } from "./base";
import { Shape } from "./base"; import { Shape } from "./base";
import { floatToDisplay } from "./floats"; import { floatToDisplay } from "./lib";
import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position"; import { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from "./position";
export class Rectangle extends Shape { export class Rectangle extends Shape {

View file

@ -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 { ConstructorParams, Size } from "../types";
import type { ShapeDataForCloze } from "./base"; import type { ShapeDataForCloze } from "./base";
import { Shape } from "./base"; import { Shape } from "./base";
import { floatToDisplay } from "./floats"; import { floatToDisplay } from "./lib";
export class Text extends Shape { export class Text extends Shape {
text: string; text: string;

View file

@ -76,7 +76,7 @@ export const groupShapes = (canvas: fabric.Canvas): void => {
activeObject.toGroup().set({ activeObject.toGroup().set({
opacity: get(opacityStateStore) ? 0.4 : 1, opacity: get(opacityStateStore) ? 0.4 : 1,
}); }).setControlsVisibility({ mtr: false });
redraw(canvas); redraw(canvas);
}; };
@ -228,18 +228,21 @@ const setShapePosition = (
boundingBox: fabric.Rect, boundingBox: fabric.Rect,
object: fabric.Object, object: fabric.Object,
): void => { ): void => {
if (object.left! < 0) { const { left, top, width, height } = object.getBoundingRect(true);
object.set({ left: 0 });
if (left < 0) {
object.set({ left: Math.max(object.left! - left, 0) });
} }
if (object.top! < 0) { if (top < 0) {
object.set({ top: 0 }); object.set({ top: Math.max(object.top! - top, 0) });
} }
if (object.left! + object.width! * object.scaleX! + object.strokeWidth! > boundingBox.width!) { if (left > boundingBox.width!) {
object.set({ left: boundingBox.width! - object.width! * object.scaleX! }); object.set({ left: object.left! - left - width + boundingBox.width! });
} }
if (object.top! + object.height! * object.scaleY! + object.strokeWidth! > boundingBox.height!) { if (top > boundingBox.height!) {
object.set({ top: boundingBox.height! - object.height! * object.scaleY! }); object.set({ top: object.top! - top - height + boundingBox.height! });
} }
object.setCoords(); object.setCoords();
}; };
@ -277,23 +280,25 @@ export const makeShapesRemainInCanvas = (canvas: fabric.Canvas, boundingBox: fab
canvas.on("object:moving", function(e) { canvas.on("object:moving", function(e) {
const obj = e.target!; const obj = e.target!;
const objWidth = obj.getScaledWidth(); const { left: objBbLeft, top: objBbTop, width: objBbWidth, height: objBbHeight } = obj.getBoundingRect(
const objHeight = obj.getScaledHeight(); true,
true,
);
if (objWidth > boundingBox.width! || objHeight > boundingBox.height!) { if (objBbWidth > boundingBox.width! || objBbHeight > boundingBox.height!) {
return; return;
} }
const top = obj.top!;
const left = obj.left!;
const topBound = boundingBox.top!; const topBound = boundingBox.top!;
const bottomBound = topBound + boundingBox.height! + 5; const bottomBound = topBound + boundingBox.height! + 5;
const leftBound = boundingBox.left!; const leftBound = boundingBox.left!;
const rightBound = leftBound + boundingBox.width! + 5; const rightBound = leftBound + boundingBox.width! + 5;
obj.left = Math.min(Math.max(left, leftBound), rightBound - objWidth); const newBbLeft = Math.min(Math.max(objBbLeft, leftBound), rightBound - objBbWidth);
obj.top = Math.min(Math.max(top, topBound), bottomBound - objHeight); const newBbTop = Math.min(Math.max(objBbTop, topBound), bottomBound - objBbHeight);
obj.left = obj.left! + newBbLeft - objBbLeft;
obj.top = obj.top! + newBbTop - objBbTop;
}); });
}; };