Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,5 +668,44 @@ function KonvaObjectInner({
);
}

if (obj.type === "circle") {
const p = obj.props;
const r = dotsToPx(p.diameter, scale, dpmm) / 2;
const stroke = p.color === "B" ? "#000000" : "#cccccc";
const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5);
const fill = p.filled
? p.color === "B"
? "#000000"
: "#ffffff"
: "transparent";
return (
<Circle
id={obj.id}
x={x + r}
y={y + r}
radius={r}
stroke={isSelected ? "#6366f1" : stroke}
strokeWidth={isSelected ? Math.max(strokeWidth, 1.5) : strokeWidth}
strokeScaleEnabled={false}
fill={fill}
draggable
onClick={(e) =>
onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey)
}
onTap={() => onSelect(false)}
onDragMove={(e) => {
const snapped = snapPos(e.target.x() - r, e.target.y() - r);
e.target.position({ x: snapped.x + r, y: snapped.y + r });
}}
onDragEnd={(e) => {
onChange({
x: pxToDots(e.target.x() - r - offsetX, scale, dpmm),
y: pxToDots(e.target.y() - r - offsetY, scale, dpmm),
});
}}
/>
);
}

return null;
}
7 changes: 6 additions & 1 deletion src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isTopAnchorResize,
transformNodeTopLeft,
positionDidMove,
forceSquareBox,
type BoundingBox,
} from "../transformerGeometry";
import { modelPositionFromRenderedTopLeft } from "../transformPosition";
Expand Down Expand Up @@ -194,14 +195,17 @@ export function useKonvaTransformer({
selectedIds.length === 1
? objects.find((o) => o.id === selectedIds[0])?.type ?? ""
: "";
const isUniformScale = !!ObjectRegistry[singleType]?.uniformScale;
const enabledAnchors: string[] | undefined =
selectedIds.length > 1
? []
: ObjectRegistry[singleType]?.heightLocked
? []
: BARCODE_1D_TYPES.has(singleType)
? ["top-center", "bottom-center"]
: undefined;
: isUniformScale
? ["top-left", "top-right", "bottom-left", "bottom-right"]
: undefined;
const isFreeResize = enabledAnchors === undefined;

/** Reset all transform-time state. Idempotent; safe to call from any exit path. */
Expand All @@ -228,6 +232,7 @@ export function useKonvaTransformer({

const boundBoxFunc = (oldBox: BoundingBox, newBox: BoundingBox): BoundingBox => {
if (newBox.width < 10 || newBox.height < 10) return oldBox;
if (isUniformScale) newBox = forceSquareBox(oldBox, newBox);
const dotPx = scale / dpmm;
let bbox = applyHeightSnap(oldBox, newBox, dotPx, transformAnchorRef.current);

Expand Down
35 changes: 35 additions & 0 deletions src/components/Canvas/transformerGeometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isTopAnchorResize,
transformNodeTopLeft,
positionDidMove,
forceSquareBox,
} from "./transformerGeometry";

describe("snapBoxHeight", () => {
Expand Down Expand Up @@ -93,3 +94,37 @@ describe("positionDidMove", () => {
expect(positionDidMove(80, 100)).toBe(true);
});
});

describe("forceSquareBox", () => {
const oldBox = { x: 100, y: 100, width: 50, height: 50, rotation: 0 };

it("clamps to max axis when dragging the bottom-right corner", () => {
const newBox = { x: 100, y: 100, width: 80, height: 60, rotation: 0 };
expect(forceSquareBox(oldBox, newBox)).toEqual({
x: 100, y: 100, width: 80, height: 80, rotation: 0,
});
});

it("pins the bottom-right corner when dragging the top-left", () => {
const newBox = { x: 70, y: 80, width: 80, height: 70, rotation: 0 };
// Bottom-right of oldBox = (150, 150). Square of size 80 must end there.
expect(forceSquareBox(oldBox, newBox)).toEqual({
x: 70, y: 70, width: 80, height: 80, rotation: 0,
});
});

it("pins the bottom-left corner when dragging the top-right", () => {
const newBox = { x: 100, y: 80, width: 70, height: 70, rotation: 0 };
expect(forceSquareBox(oldBox, newBox)).toEqual({
x: 100, y: 80, width: 70, height: 70, rotation: 0,
});
});

it("pins the top-right corner when dragging the bottom-left", () => {
const newBox = { x: 80, y: 100, width: 70, height: 70, rotation: 0 };
// Top-right of oldBox = (150, 100). Square of size 70 stays there.
expect(forceSquareBox(oldBox, newBox)).toEqual({
x: 80, y: 100, width: 70, height: 70, rotation: 0,
});
});
});
17 changes: 17 additions & 0 deletions src/components/Canvas/transformerGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ export function snapBoxHeight(height: number, stepPx: number): number {
return Math.max(stepPx, Math.round(height / stepPx) * stepPx);
}

/**
* Forces newBox to be square while keeping the anchor corner pinned.
*
* Konva does not expose the active anchor to boundBoxFunc, so it is inferred
* from which oldBox edges moved: an edge that did not move is the pinned
* side. The new size is the larger of the two requested deltas, so either
* axis the user pulls drives the resize.
*/
export function forceSquareBox(oldBox: BoundingBox, newBox: BoundingBox): BoundingBox {
const leftMoved = Math.abs(newBox.x - oldBox.x) > 0.001;
const topMoved = Math.abs(newBox.y - oldBox.y) > 0.001;
const size = Math.max(Math.abs(newBox.width), Math.abs(newBox.height));
const x = leftMoved ? oldBox.x + oldBox.width - size : oldBox.x;
const y = topMoved ? oldBox.y + oldBox.height - size : oldBox.y;
return { ...newBox, x, y, width: size, height: size };
}

/**
* Adjust newBox so its bottom edge stays at oldBox's bottom (top-anchor resize)
* with a height of snappedH. Used when the user drags the top handle.
Expand Down
7 changes: 1 addition & 6 deletions src/components/Properties/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "../../lib/units";
import type { Unit } from "../../lib/units";
import { useT } from "../../lib/useT";
import { parseIntOrUndef } from "../../lib/inputParse";
import { inputCls, labelCls } from "./styles";
import type { LabelConfig } from "../../types/ObjectType";

Expand Down Expand Up @@ -464,9 +465,3 @@ function LabelConfigPanel({
</div>
);
}

function parseIntOrUndef(raw: string): number | undefined {
if (raw.trim() === "") return undefined;
const n = parseInt(raw, 10);
return isNaN(n) ? undefined : n;
}
57 changes: 57 additions & 0 deletions src/lib/inputParse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { parseIntOrUndef, clampMin } from './inputParse';

describe('parseIntOrUndef', () => {
it('returns undefined for empty input', () => {
expect(parseIntOrUndef('')).toBeUndefined();
expect(parseIntOrUndef(' ')).toBeUndefined();
});

it('returns undefined for unparsable input', () => {
expect(parseIntOrUndef('abc')).toBeUndefined();
});

it('parses positive integers', () => {
expect(parseIntOrUndef('42')).toBe(42);
});

it('parses negative integers', () => {
expect(parseIntOrUndef('-7')).toBe(-7);
});

it('preserves 0 as a valid value', () => {
expect(parseIntOrUndef('0')).toBe(0);
});

it('truncates fractional input toward zero', () => {
expect(parseIntOrUndef('3.7')).toBe(3);
});
});

describe('clampMin', () => {
it('returns the parsed value when above min', () => {
expect(clampMin('5', 1)).toBe(5);
});

it('returns min when input is empty', () => {
expect(clampMin('', 1)).toBe(1);
});

it('returns min when input is below the floor', () => {
expect(clampMin('0', 1)).toBe(1);
expect(clampMin('-3', 1)).toBe(1);
});

it('returns min when input is unparsable', () => {
expect(clampMin('abc', 1)).toBe(1);
});

it('preserves fractional inputs above the floor', () => {
expect(clampMin('2.5', 1)).toBe(2.5);
});

it('respects custom floors other than 1', () => {
expect(clampMin('5', 10)).toBe(10);
expect(clampMin('15', 10)).toBe(15);
});
});
29 changes: 29 additions & 0 deletions src/lib/inputParse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Helpers for sanitising raw `<input>` values into typed model fields.
*
* `<input type="number" min="…">` enforces nothing on the value the change
* handler receives — `min` is only a UI hint and `Number("")` collapses to 0.
* These helpers give callers a one-liner that turns the raw string into
* a value the model can safely accept.
*/

/**
* Parses an integer from a raw input value, returning `undefined` when the
* field is empty or unparsable. Use for optional number fields where
* "absent" is a valid persisted state.
*/
export function parseIntOrUndef(raw: string): number | undefined {
if (raw.trim() === '') return undefined;
const n = parseInt(raw, 10);
return isNaN(n) ? undefined : n;
}

/**
* Parses a number from a raw input value and clamps it to at least `min`.
* Empty / NaN / sub-min inputs collapse to `min`. Use for required number
* fields that need a hard lower bound (shape dimensions, line widths).
*/
export function clampMin(raw: string, min: number): number {
const n = Number(raw);
return isNaN(n) || n < min ? min : n;
}
9 changes: 9 additions & 0 deletions src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const ar = {
datamatrix: 'DataMatrix',
box: 'مستطيل',
ellipse: 'قطع ناقص',
circle: 'دائرة',
line: 'خط',
serial: 'رقم تسلسلي',
image: 'صورة',
Expand Down Expand Up @@ -200,6 +201,14 @@ const ar = {
colorB: 'B — أسود',
colorW: 'W — أبيض',
},
circle: {
diameter: 'القطر (نقاط)',
thickness: 'الحدود (نقاط)',
filled: 'ممتلئ',
color: 'اللون',
colorB: 'B — أسود',
colorW: 'W — أبيض',
},
line: {
angle: 'الزاوية (°)',
length: 'الطول (نقطة)',
Expand Down
9 changes: 9 additions & 0 deletions src/locales/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const bg = {
datamatrix: 'DataMatrix',
box: 'Правоъгълник',
ellipse: 'Елипса',
circle: 'Кръг',
line: 'Линия',
serial: 'Сериен №',
image: 'Изображение',
Expand Down Expand Up @@ -200,6 +201,14 @@ const bg = {
colorB: 'B — Черен',
colorW: 'W — Бял',
},
circle: {
diameter: 'Диаметър (точки)',
thickness: 'Рамка (точки)',
filled: 'Запълнено',
color: 'Цвят',
colorB: 'B — Черно',
colorW: 'W — Бяло',
},
line: {
angle: 'Ъгъл (°)',
length: 'Дължина (точки)',
Expand Down
9 changes: 9 additions & 0 deletions src/locales/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const cs = {
datamatrix: 'DataMatrix',
box: 'Obdélník',
ellipse: 'Elipsa',
circle: 'Kruh',
line: 'Čára',
serial: 'Sériové číslo',
image: 'Obrázek',
Expand Down Expand Up @@ -200,6 +201,14 @@ const cs = {
colorB: 'B — Černá',
colorW: 'W — Bílá',
},
circle: {
diameter: 'Průměr (body)',
thickness: 'Okraj (body)',
filled: 'Vyplněný',
color: 'Barva',
colorB: 'B — Černá',
colorW: 'W — Bílá',
},
line: {
angle: 'Úhel (°)',
length: 'Délka (body)',
Expand Down
9 changes: 9 additions & 0 deletions src/locales/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const da = {
datamatrix: 'DataMatrix',
box: 'Rektangel',
ellipse: 'Ellipse',
circle: 'Cirkel',
line: 'Linje',
serial: 'Serienr.',
image: 'Billede',
Expand Down Expand Up @@ -200,6 +201,14 @@ const da = {
colorB: 'B — Sort',
colorW: 'W — Hvid',
},
circle: {
diameter: 'Diameter (punkter)',
thickness: 'Ramme (punkter)',
filled: 'Fyldt',
color: 'Farve',
colorB: 'B — Sort',
colorW: 'W — Hvid',
},
line: {
angle: 'Vinkel (°)',
length: 'Længde (punkter)',
Expand Down
9 changes: 9 additions & 0 deletions src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const de = {
datamatrix: 'DataMatrix',
box: 'Box',
ellipse: 'Ellipse',
circle: 'Kreis',
line: 'Linie',
serial: 'Seriennummer',
image: 'Bild',
Expand Down Expand Up @@ -220,6 +221,14 @@ const de = {
colorB: 'B — Schwarz',
colorW: 'W — Weiß',
},
circle: {
diameter: 'Durchmesser (Punkte)',
thickness: 'Rahmen (Punkte)',
filled: 'Gefüllt',
color: 'Farbe',
colorB: 'B — Schwarz',
colorW: 'W — Weiß',
},
line: {
angle: 'Winkel (°)',
length: 'Länge (Punkte)',
Expand Down
9 changes: 9 additions & 0 deletions src/locales/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const el = {
datamatrix: 'DataMatrix',
box: 'Ορθογώνιο',
ellipse: 'Έλλειψη',
circle: 'Κύκλος',
line: 'Γραμμή',
serial: 'Σειριακός αρ.',
image: 'Εικόνα',
Expand Down Expand Up @@ -200,6 +201,14 @@ const el = {
colorB: 'B — Μαύρο',
colorW: 'W — Λευκό',
},
circle: {
diameter: 'Διάμετρος (κουκκίδες)',
thickness: 'Περίγραμμα (κουκκίδες)',
filled: 'Γεμάτο',
color: 'Χρώμα',
colorB: 'B — Μαύρο',
colorW: 'W — Λευκό',
},
line: {
angle: 'Γωνία (°)',
length: 'Μήκος (κουκκίδες)',
Expand Down
Loading