diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 130ff87f..44ff1492 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -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 ( + + 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; } diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 3644277e..cb891cb7 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -11,6 +11,7 @@ import { isTopAnchorResize, transformNodeTopLeft, positionDidMove, + forceSquareBox, type BoundingBox, } from "../transformerGeometry"; import { modelPositionFromRenderedTopLeft } from "../transformPosition"; @@ -194,6 +195,7 @@ 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 ? [] @@ -201,7 +203,9 @@ export function useKonvaTransformer({ ? [] : 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. */ @@ -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); diff --git a/src/components/Canvas/transformerGeometry.test.ts b/src/components/Canvas/transformerGeometry.test.ts index 044d8c5a..834fe77d 100644 --- a/src/components/Canvas/transformerGeometry.test.ts +++ b/src/components/Canvas/transformerGeometry.test.ts @@ -5,6 +5,7 @@ import { isTopAnchorResize, transformNodeTopLeft, positionDidMove, + forceSquareBox, } from "./transformerGeometry"; describe("snapBoxHeight", () => { @@ -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, + }); + }); +}); diff --git a/src/components/Canvas/transformerGeometry.ts b/src/components/Canvas/transformerGeometry.ts index 1d554c03..8f0f85bf 100644 --- a/src/components/Canvas/transformerGeometry.ts +++ b/src/components/Canvas/transformerGeometry.ts @@ -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. diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 4b6f3592..9dd9f4d1 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -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"; @@ -464,9 +465,3 @@ function LabelConfigPanel({ ); } - -function parseIntOrUndef(raw: string): number | undefined { - if (raw.trim() === "") return undefined; - const n = parseInt(raw, 10); - return isNaN(n) ? undefined : n; -} diff --git a/src/lib/inputParse.test.ts b/src/lib/inputParse.test.ts new file mode 100644 index 00000000..5c186a01 --- /dev/null +++ b/src/lib/inputParse.test.ts @@ -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); + }); +}); diff --git a/src/lib/inputParse.ts b/src/lib/inputParse.ts new file mode 100644 index 00000000..5f53602b --- /dev/null +++ b/src/lib/inputParse.ts @@ -0,0 +1,29 @@ +/** + * Helpers for sanitising raw `` values into typed model fields. + * + * `` 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; +} diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 7c2c52d1..7f1cbc9c 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -19,6 +19,7 @@ const ar = { datamatrix: 'DataMatrix', box: 'مستطيل', ellipse: 'قطع ناقص', + circle: 'دائرة', line: 'خط', serial: 'رقم تسلسلي', image: 'صورة', @@ -200,6 +201,14 @@ const ar = { colorB: 'B — أسود', colorW: 'W — أبيض', }, + circle: { + diameter: 'القطر (نقاط)', + thickness: 'الحدود (نقاط)', + filled: 'ممتلئ', + color: 'اللون', + colorB: 'B — أسود', + colorW: 'W — أبيض', + }, line: { angle: 'الزاوية (°)', length: 'الطول (نقطة)', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index af9ac738..c61e9c18 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -19,6 +19,7 @@ const bg = { datamatrix: 'DataMatrix', box: 'Правоъгълник', ellipse: 'Елипса', + circle: 'Кръг', line: 'Линия', serial: 'Сериен №', image: 'Изображение', @@ -200,6 +201,14 @@ const bg = { colorB: 'B — Черен', colorW: 'W — Бял', }, + circle: { + diameter: 'Диаметър (точки)', + thickness: 'Рамка (точки)', + filled: 'Запълнено', + color: 'Цвят', + colorB: 'B — Черно', + colorW: 'W — Бяло', + }, line: { angle: 'Ъгъл (°)', length: 'Дължина (точки)', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 032386b1..a1dacb1f 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -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', @@ -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)', diff --git a/src/locales/da.ts b/src/locales/da.ts index 83b16463..2a171b9e 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -19,6 +19,7 @@ const da = { datamatrix: 'DataMatrix', box: 'Rektangel', ellipse: 'Ellipse', + circle: 'Cirkel', line: 'Linje', serial: 'Serienr.', image: 'Billede', @@ -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)', diff --git a/src/locales/de.ts b/src/locales/de.ts index 0313d557..951f389d 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -19,6 +19,7 @@ const de = { datamatrix: 'DataMatrix', box: 'Box', ellipse: 'Ellipse', + circle: 'Kreis', line: 'Linie', serial: 'Seriennummer', image: 'Bild', @@ -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)', diff --git a/src/locales/el.ts b/src/locales/el.ts index 94ce4ced..f6505c36 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -19,6 +19,7 @@ const el = { datamatrix: 'DataMatrix', box: 'Ορθογώνιο', ellipse: 'Έλλειψη', + circle: 'Κύκλος', line: 'Γραμμή', serial: 'Σειριακός αρ.', image: 'Εικόνα', @@ -200,6 +201,14 @@ const el = { colorB: 'B — Μαύρο', colorW: 'W — Λευκό', }, + circle: { + diameter: 'Διάμετρος (κουκκίδες)', + thickness: 'Περίγραμμα (κουκκίδες)', + filled: 'Γεμάτο', + color: 'Χρώμα', + colorB: 'B — Μαύρο', + colorW: 'W — Λευκό', + }, line: { angle: 'Γωνία (°)', length: 'Μήκος (κουκκίδες)', diff --git a/src/locales/en.ts b/src/locales/en.ts index 1edb0d32..5f2d53f2 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -19,6 +19,7 @@ const en = { datamatrix: 'DataMatrix', box: 'Box', ellipse: 'Ellipse', + circle: 'Circle', line: 'Line', serial: 'Serial', image: 'Image', @@ -220,6 +221,14 @@ const en = { colorB: 'B — Black', colorW: 'W — White', }, + circle: { + diameter: 'Diameter (dots)', + thickness: 'Border (dots)', + filled: 'Filled', + color: 'Color', + colorB: 'B — Black', + colorW: 'W — White', + }, line: { angle: 'Angle (°)', length: 'Length (dots)', diff --git a/src/locales/es.ts b/src/locales/es.ts index 946509b1..fc240236 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -19,6 +19,7 @@ const es = { datamatrix: 'DataMatrix', box: 'Rectángulo', ellipse: 'Elipse', + circle: 'Círculo', line: 'Línea', serial: 'Serie', image: 'Imagen', @@ -200,6 +201,14 @@ const es = { colorB: 'B — Negro', colorW: 'W — Blanco', }, + circle: { + diameter: 'Diámetro (puntos)', + thickness: 'Borde (puntos)', + filled: 'Relleno', + color: 'Color', + colorB: 'B — Negro', + colorW: 'W — Blanco', + }, line: { angle: 'Ángulo (°)', length: 'Longitud (puntos)', diff --git a/src/locales/et.ts b/src/locales/et.ts index 57947bfb..fd118a7d 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -19,6 +19,7 @@ const et = { datamatrix: 'DataMatrix', box: 'Ristkülik', ellipse: 'Ellips', + circle: 'Ring', line: 'Joon', serial: 'Seerianr', image: 'Pilt', @@ -200,6 +201,14 @@ const et = { colorB: 'B — Must', colorW: 'W — Valge', }, + circle: { + diameter: 'Läbimõõt (punktid)', + thickness: 'Ääris (punktid)', + filled: 'Täidetud', + color: 'Värv', + colorB: 'B — Must', + colorW: 'W — Valge', + }, line: { angle: 'Nurk (°)', length: 'Pikkus (punkti)', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index b2d1dcce..10aa70aa 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -19,6 +19,7 @@ const fa = { datamatrix: 'DataMatrix', box: 'مستطیل', ellipse: 'بیضی', + circle: 'دایره', line: 'خط', serial: 'شماره سریال', image: 'تصویر', @@ -200,6 +201,14 @@ const fa = { colorB: 'B — مشکی', colorW: 'W — سفید', }, + circle: { + diameter: 'قطر (نقطه)', + thickness: 'حاشیه (نقطه)', + filled: 'پر شده', + color: 'رنگ', + colorB: 'B — سیاه', + colorW: 'W — سفید', + }, line: { angle: 'زاویه (°)', length: 'طول (نقطه)', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 51fd3101..d944d4ad 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -19,6 +19,7 @@ const fi = { datamatrix: 'DataMatrix', box: 'Suorakulmio', ellipse: 'Ellipsi', + circle: 'Ympyrä', line: 'Viiva', serial: 'Sarjanro', image: 'Kuva', @@ -200,6 +201,14 @@ const fi = { colorB: 'B — Musta', colorW: 'W — Valkoinen', }, + circle: { + diameter: 'Halkaisija (pisteet)', + thickness: 'Reuna (pisteet)', + filled: 'Täytetty', + color: 'Väri', + colorB: 'B — Musta', + colorW: 'W — Valkoinen', + }, line: { angle: 'Kulma (°)', length: 'Pituus (pistettä)', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 9e7f086b..61069440 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -19,6 +19,7 @@ const fr = { datamatrix: 'DataMatrix', box: 'Rectangle', ellipse: 'Ellipse', + circle: 'Cercle', line: 'Ligne', serial: 'Série', image: 'Image', @@ -200,6 +201,14 @@ const fr = { colorB: 'B — Noir', colorW: 'W — Blanc', }, + circle: { + diameter: 'Diamètre (points)', + thickness: 'Bordure (points)', + filled: 'Rempli', + color: 'Couleur', + colorB: 'B — Noir', + colorW: 'W — Blanc', + }, line: { angle: 'Angle (°)', length: 'Longueur (points)', diff --git a/src/locales/he.ts b/src/locales/he.ts index eb7d01d8..470a1a49 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -19,6 +19,7 @@ const he = { datamatrix: 'DataMatrix', box: 'מלבן', ellipse: 'אליפסה', + circle: 'עיגול', line: 'קו', serial: 'מס. סידורי', image: 'תמונה', @@ -200,6 +201,14 @@ const he = { colorB: 'B — שחור', colorW: 'W — לבן', }, + circle: { + diameter: 'קוטר (נקודות)', + thickness: 'מסגרת (נקודות)', + filled: 'מלא', + color: 'צבע', + colorB: 'B — שחור', + colorW: 'W — לבן', + }, line: { angle: 'זווית (°)', length: 'אורך (נקודות)', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index be9d2e4c..7a3200e4 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -19,6 +19,7 @@ const hr = { datamatrix: 'DataMatrix', box: 'Pravokutnik', ellipse: 'Elipsa', + circle: 'Krug', line: 'Linija', serial: 'Serijski br.', image: 'Slika', @@ -200,6 +201,14 @@ const hr = { colorB: 'B — Crna', colorW: 'W — Bijela', }, + circle: { + diameter: 'Promjer (točke)', + thickness: 'Obrub (točke)', + filled: 'Ispunjeno', + color: 'Boja', + colorB: 'B — Crno', + colorW: 'W — Bijelo', + }, line: { angle: 'Kut (°)', length: 'Duljina (točke)', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index bdcc01ce..aa851d00 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -19,6 +19,7 @@ const hu = { datamatrix: 'DataMatrix', box: 'Téglalap', ellipse: 'Ellipszis', + circle: 'Kör', line: 'Vonal', serial: 'Sorszám', image: 'Kép', @@ -200,6 +201,14 @@ const hu = { colorB: 'B — Fekete', colorW: 'W — Fehér', }, + circle: { + diameter: 'Átmérő (pontok)', + thickness: 'Keret (pontok)', + filled: 'Kitöltött', + color: 'Szín', + colorB: 'B — Fekete', + colorW: 'W — Fehér', + }, line: { angle: 'Szög (°)', length: 'Hossz (pont)', diff --git a/src/locales/it.ts b/src/locales/it.ts index d5816a5c..b65b58f5 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -19,6 +19,7 @@ const it = { datamatrix: 'DataMatrix', box: 'Rettangolo', ellipse: 'Ellisse', + circle: 'Cerchio', line: 'Linea', serial: 'Seriale', image: 'Immagine', @@ -200,6 +201,14 @@ const it = { colorB: 'B — Nero', colorW: 'W — Bianco', }, + circle: { + diameter: 'Diametro (punti)', + thickness: 'Bordo (punti)', + filled: 'Riempito', + color: 'Colore', + colorB: 'B — Nero', + colorW: 'W — Bianco', + }, line: { angle: 'Angolo (°)', length: 'Lunghezza (punti)', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index d5bcc6e8..272ab627 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -19,6 +19,7 @@ const ja = { datamatrix: 'DataMatrix', box: '矩形', ellipse: '楕円', + circle: '円', line: '線', serial: 'シリアル', image: '画像', @@ -200,6 +201,14 @@ const ja = { colorB: 'B — 黒', colorW: 'W — 白', }, + circle: { + diameter: '直径 (ドット)', + thickness: '枠線 (ドット)', + filled: '塗りつぶし', + color: '色', + colorB: 'B — 黒', + colorW: 'W — 白', + }, line: { angle: '角度 (°)', length: '長さ (ドット)', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 857446c9..f88fc60c 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -19,6 +19,7 @@ const ko = { datamatrix: 'DataMatrix', box: '사각형', ellipse: '타원', + circle: '원', line: '선', serial: '일련번호', image: '이미지', @@ -200,6 +201,14 @@ const ko = { colorB: 'B — 검정', colorW: 'W — 흰색', }, + circle: { + diameter: '지름 (도트)', + thickness: '테두리 (도트)', + filled: '채움', + color: '색상', + colorB: 'B — 검정', + colorW: 'W — 흰색', + }, line: { angle: '각도 (°)', length: '길이 (점)', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 89ba678a..baad5248 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -19,6 +19,7 @@ const lt = { datamatrix: 'DataMatrix', box: 'Stačiakampis', ellipse: 'Elipsė', + circle: 'Apskritimas', line: 'Linija', serial: 'Serijinis nr.', image: 'Vaizdas', @@ -200,6 +201,14 @@ const lt = { colorB: 'B — Juoda', colorW: 'W — Balta', }, + circle: { + diameter: 'Skersmuo (taškai)', + thickness: 'Rėmelis (taškai)', + filled: 'Užpildytas', + color: 'Spalva', + colorB: 'B — Juoda', + colorW: 'W — Balta', + }, line: { angle: 'Kampas (°)', length: 'Ilgis (taškai)', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 5fe0c76c..45b83679 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -19,6 +19,7 @@ const lv = { datamatrix: 'DataMatrix', box: 'Taisnstūris', ellipse: 'Elipse', + circle: 'Aplis', line: 'Līnija', serial: 'Sērijas nr.', image: 'Attēls', @@ -200,6 +201,14 @@ const lv = { colorB: 'B — Melna', colorW: 'W — Balta', }, + circle: { + diameter: 'Diametrs (punkti)', + thickness: 'Apmale (punkti)', + filled: 'Aizpildīts', + color: 'Krāsa', + colorB: 'B — Melns', + colorW: 'W — Balts', + }, line: { angle: 'Leņķis (°)', length: 'Garums (punkti)', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 0079d09c..bdbc9ee0 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -19,6 +19,7 @@ const nl = { datamatrix: 'DataMatrix', box: 'Rechthoek', ellipse: 'Ellips', + circle: 'Cirkel', line: 'Lijn', serial: 'Serienummer', image: 'Afbeelding', @@ -200,6 +201,14 @@ const nl = { colorB: 'B — Zwart', colorW: 'W — Wit', }, + circle: { + diameter: 'Diameter (dots)', + thickness: 'Rand (dots)', + filled: 'Gevuld', + color: 'Kleur', + colorB: 'B — Zwart', + colorW: 'W — Wit', + }, line: { angle: 'Hoek (°)', length: 'Lengte (punten)', diff --git a/src/locales/no.ts b/src/locales/no.ts index 0fbceff6..6a5ae433 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -19,6 +19,7 @@ const no = { datamatrix: 'DataMatrix', box: 'Rektangel', ellipse: 'Ellipse', + circle: 'Sirkel', line: 'Linje', serial: 'Serienr.', image: 'Bilde', @@ -200,6 +201,14 @@ const no = { colorB: 'B — Svart', colorW: 'W — Hvit', }, + circle: { + diameter: 'Diameter (punkter)', + thickness: 'Ramme (punkter)', + filled: 'Fylt', + color: 'Farge', + colorB: 'B — Svart', + colorW: 'W — Hvit', + }, line: { angle: 'Vinkel (°)', length: 'Lengde (punkter)', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 955eadc3..935a7972 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -19,6 +19,7 @@ const pl = { datamatrix: 'DataMatrix', box: 'Prostokąt', ellipse: 'Elipsa', + circle: 'Okrąg', line: 'Linia', serial: 'Seria', image: 'Obraz', @@ -200,6 +201,14 @@ const pl = { colorB: 'B — Czarny', colorW: 'W — Biały', }, + circle: { + diameter: 'Średnica (punkty)', + thickness: 'Obramowanie (punkty)', + filled: 'Wypełniony', + color: 'Kolor', + colorB: 'B — Czarny', + colorW: 'W — Biały', + }, line: { angle: 'Kąt (°)', length: 'Długość (punkty)', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 60550b23..6c8ca50f 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -19,6 +19,7 @@ const pt = { datamatrix: 'DataMatrix', box: 'Retângulo', ellipse: 'Elipse', + circle: 'Círculo', line: 'Linha', serial: 'Série', image: 'Imagem', @@ -200,6 +201,14 @@ const pt = { colorB: 'B — Preto', colorW: 'W — Branco', }, + circle: { + diameter: 'Diâmetro (pontos)', + thickness: 'Borda (pontos)', + filled: 'Preenchido', + color: 'Cor', + colorB: 'B — Preto', + colorW: 'W — Branco', + }, line: { angle: 'Ângulo (°)', length: 'Comprimento (pontos)', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 34eee4d9..824624fd 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -19,6 +19,7 @@ const ro = { datamatrix: 'DataMatrix', box: 'Dreptunghi', ellipse: 'Elipsă', + circle: 'Cerc', line: 'Linie', serial: 'Serie', image: 'Imagine', @@ -200,6 +201,14 @@ const ro = { colorB: 'B — Negru', colorW: 'W — Alb', }, + circle: { + diameter: 'Diametru (puncte)', + thickness: 'Bordură (puncte)', + filled: 'Umplut', + color: 'Culoare', + colorB: 'B — Negru', + colorW: 'W — Alb', + }, line: { angle: 'Unghi (°)', length: 'Lungime (puncte)', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 4390cceb..32a047a0 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -19,6 +19,7 @@ const sk = { datamatrix: 'DataMatrix', box: 'Obdĺžnik', ellipse: 'Elipsa', + circle: 'Kruh', line: 'Čiara', serial: 'Sériové číslo', image: 'Obrázok', @@ -200,6 +201,14 @@ const sk = { colorB: 'B — Čierna', colorW: 'W — Biela', }, + circle: { + diameter: 'Priemer (body)', + thickness: 'Okraj (body)', + filled: 'Vyplnený', + color: 'Farba', + colorB: 'B — Čierna', + colorW: 'W — Biela', + }, line: { angle: 'Uhol (°)', length: 'Dĺžka (body)', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index fb7b8866..587dcd9c 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -19,6 +19,7 @@ const sl = { datamatrix: 'DataMatrix', box: 'Pravokotnik', ellipse: 'Elipsa', + circle: 'Krog', line: 'Črta', serial: 'Zaporedna št.', image: 'Slika', @@ -200,6 +201,14 @@ const sl = { colorB: 'B — Črna', colorW: 'W — Bela', }, + circle: { + diameter: 'Premer (točke)', + thickness: 'Obroba (točke)', + filled: 'Zapolnjeno', + color: 'Barva', + colorB: 'B — Črna', + colorW: 'W — Bela', + }, line: { angle: 'Kot (°)', length: 'Dolžina (točke)', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 1f9e3e1b..37a5a23f 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -19,6 +19,7 @@ const sr = { datamatrix: 'DataMatrix', box: 'Правоугаоник', ellipse: 'Елипса', + circle: 'Круг', line: 'Линија', serial: 'Серијски бр.', image: 'Слика', @@ -200,6 +201,14 @@ const sr = { colorB: 'B — Crna', colorW: 'W — Bela', }, + circle: { + diameter: 'Пречник (тачке)', + thickness: 'Оквир (тачке)', + filled: 'Испуњено', + color: 'Боја', + colorB: 'B — Црна', + colorW: 'W — Бела', + }, line: { angle: 'Ugao (°)', length: 'Dužina (tačke)', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 8a13cac4..2650af14 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -19,6 +19,7 @@ const sv = { datamatrix: 'DataMatrix', box: 'Rektangel', ellipse: 'Ellips', + circle: 'Cirkel', line: 'Linje', serial: 'Serienr.', image: 'Bild', @@ -200,6 +201,14 @@ const sv = { colorB: 'B — Svart', colorW: 'W — Vit', }, + circle: { + diameter: 'Diameter (punkter)', + thickness: 'Kant (punkter)', + filled: 'Fylld', + color: 'Färg', + colorB: 'B — Svart', + colorW: 'W — Vit', + }, line: { angle: 'Vinkel (°)', length: 'Längd (punkter)', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index d31a24d8..13d43ff7 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -19,6 +19,7 @@ const tr = { datamatrix: 'DataMatrix', box: 'Dikdörtgen', ellipse: 'Elips', + circle: 'Daire', line: 'Çizgi', serial: 'Seri No', image: 'Görsel', @@ -200,6 +201,14 @@ const tr = { colorB: 'B — Siyah', colorW: 'W — Beyaz', }, + circle: { + diameter: 'Çap (nokta)', + thickness: 'Kenarlık (nokta)', + filled: 'Dolu', + color: 'Renk', + colorB: 'B — Siyah', + colorW: 'W — Beyaz', + }, line: { angle: 'Açı (°)', length: 'Uzunluk (nokta)', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 37fa2b55..37c1586e 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -19,6 +19,7 @@ const zhHans = { datamatrix: 'DataMatrix', box: '矩形', ellipse: '椭圆', + circle: '圆', line: '线条', serial: '序列号', image: '图片', @@ -200,6 +201,14 @@ const zhHans = { colorB: 'B — 黑色', colorW: 'W — 白色', }, + circle: { + diameter: '直径 (点)', + thickness: '边框 (点)', + filled: '填充', + color: '颜色', + colorB: 'B — 黑色', + colorW: 'W — 白色', + }, line: { angle: '角度 (°)', length: '长度 (点)', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 6cc82cc4..48fae74b 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -19,6 +19,7 @@ const zhHant = { datamatrix: 'DataMatrix', box: '矩形', ellipse: '橢圓', + circle: '圓', line: '線條', serial: '序號', image: '圖片', @@ -200,6 +201,14 @@ const zhHant = { colorB: 'B — 黑色', colorW: 'W — 白色', }, + circle: { + diameter: '直徑 (點)', + thickness: '邊框 (點)', + filled: '填充', + color: '顏色', + colorB: 'B — 黑色', + colorW: 'W — 白色', + }, line: { angle: '角度 (°)', length: '長度 (點)', diff --git a/src/registry/box.tsx b/src/registry/box.tsx index b55ff420..1c56847c 100644 --- a/src/registry/box.tsx +++ b/src/registry/box.tsx @@ -3,6 +3,7 @@ import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos } from './zplHelpers'; import { commitWidthHeightTransform } from './transformHelpers'; +import { clampMin } from '../lib/inputParse'; export interface BoxProps { width: number; @@ -55,7 +56,7 @@ export const box: ObjectTypeDefinition = { className={inputCls} value={p.width} min={1} - onChange={(e) => onChange({ width: Number(e.target.value) })} + onChange={(e) => onChange({ width: clampMin(e.target.value, 1) })} />
@@ -65,7 +66,7 @@ export const box: ObjectTypeDefinition = { className={inputCls} value={p.height} min={1} - onChange={(e) => onChange({ height: Number(e.target.value) })} + onChange={(e) => onChange({ height: clampMin(e.target.value, 1) })} />
@@ -88,7 +89,7 @@ export const box: ObjectTypeDefinition = { className={inputCls} value={p.thickness} min={1} - onChange={(e) => onChange({ thickness: Number(e.target.value) })} + onChange={(e) => onChange({ thickness: clampMin(e.target.value, 1) })} /> )} @@ -113,7 +114,7 @@ export const box: ObjectTypeDefinition = { value={p.rounding} min={0} max={8} - onChange={(e) => onChange({ rounding: Number(e.target.value) })} + onChange={(e) => onChange({ rounding: clampMin(e.target.value, 0) })} /> diff --git a/src/registry/circle.tsx b/src/registry/circle.tsx new file mode 100644 index 00000000..e5748502 --- /dev/null +++ b/src/registry/circle.tsx @@ -0,0 +1,97 @@ +import type { ObjectTypeDefinition } from '../types/ObjectType'; +import { useT } from '../lib/useT'; +import { clampMin } from '../lib/inputParse'; +import { inputCls, labelCls } from '../components/Properties/styles'; +import { fieldPos } from './zplHelpers'; + +export interface CircleProps { + diameter: number; + thickness: number; + filled: boolean; + color: 'B' | 'W'; +} + +export const circle: ObjectTypeDefinition = { + label: 'Circle', + icon: '●', + group: 'shape', + defaultProps: { + diameter: 100, + thickness: 3, + filled: false, + color: 'B', + }, + defaultSize: { width: 100, height: 100 }, + nodeOrigin: 'center', + uniformScale: true, + + // Force a uniform scale: take the smaller of the two axes so the resized + // circle stays inside the bounding box the user dragged out. + commitTransform: (obj, { sx, sy, snap }) => ({ + diameter: Math.max(1, snap(Math.round(obj.props.diameter * Math.min(sx, sy)))), + }), + + toZPL: (obj) => { + const p = obj.props; + const thick = p.filled ? p.diameter : p.thickness; + return [ + fieldPos(obj), + `^GE${p.diameter},${p.diameter},${thick},${p.color}`, + `^FS`, + ].join(''); + }, + + PropertiesPanel: ({ obj, onChange }) => { + const t = useT(); + const p = obj.props; + return ( +
+
+ + onChange({ diameter: clampMin(e.target.value, 1) })} + /> +
+ + + + {!p.filled && ( +
+ + onChange({ thickness: clampMin(e.target.value, 1) })} + /> +
+ )} + +
+ + +
+
+ ); + }, +}; diff --git a/src/registry/ellipse.tsx b/src/registry/ellipse.tsx index 3cd4d427..e5835c1e 100644 --- a/src/registry/ellipse.tsx +++ b/src/registry/ellipse.tsx @@ -3,6 +3,7 @@ import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos } from './zplHelpers'; import { commitWidthHeightTransform } from './transformHelpers'; +import { clampMin } from '../lib/inputParse'; export interface EllipseProps { width: number; @@ -51,7 +52,7 @@ export const ellipse: ObjectTypeDefinition = { className={inputCls} value={p.width} min={1} - onChange={(e) => onChange({ width: Number(e.target.value) })} + onChange={(e) => onChange({ width: clampMin(e.target.value, 1) })} />
@@ -61,7 +62,7 @@ export const ellipse: ObjectTypeDefinition = { className={inputCls} value={p.height} min={1} - onChange={(e) => onChange({ height: Number(e.target.value) })} + onChange={(e) => onChange({ height: clampMin(e.target.value, 1) })} />
@@ -84,7 +85,7 @@ export const ellipse: ObjectTypeDefinition = { className={inputCls} value={p.thickness} min={1} - onChange={(e) => onChange({ thickness: Number(e.target.value) })} + onChange={(e) => onChange({ thickness: clampMin(e.target.value, 1) })} /> )} diff --git a/src/registry/index.ts b/src/registry/index.ts index a158a9b4..be9c6d77 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -15,6 +15,8 @@ import { box } from './box.tsx'; import type { BoxProps } from './box.tsx'; import { ellipse } from './ellipse.tsx'; import type { EllipseProps } from './ellipse.tsx'; +import { circle } from './circle.tsx'; +import type { CircleProps } from './circle.tsx'; import { line } from './line.tsx'; import type { LineProps } from './line.tsx'; import { serial } from './serial.tsx'; @@ -69,6 +71,7 @@ export type LabelObject = | (LabelObjectBase & { type: 'datamatrix'; props: DataMatrixProps }) | (LabelObjectBase & { type: 'box'; props: BoxProps }) | (LabelObjectBase & { type: 'ellipse'; props: EllipseProps }) + | (LabelObjectBase & { type: 'circle'; props: CircleProps }) | (LabelObjectBase & { type: 'line'; props: LineProps }) | (LabelObjectBase & { type: 'serial'; props: SerialProps }) | (LabelObjectBase & { type: 'image'; props: ImageProps }) @@ -134,6 +137,7 @@ export const ObjectRegistry: Record> = { // shape box, ellipse, + circle, line, serial, image, diff --git a/src/registry/line.tsx b/src/registry/line.tsx index cc9a06e1..03fc9e3b 100644 --- a/src/registry/line.tsx +++ b/src/registry/line.tsx @@ -1,5 +1,6 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; +import { clampMin } from '../lib/inputParse'; import { inputCls, labelCls } from '../components/Properties/styles'; export interface LineProps { @@ -70,7 +71,7 @@ export const line: ObjectTypeDefinition = { className={inputCls} value={p.length} min={1} - onChange={(e) => onChange({ length: Number(e.target.value) })} + onChange={(e) => onChange({ length: clampMin(e.target.value, 1) })} />
@@ -93,7 +94,7 @@ export const line: ObjectTypeDefinition = { className={inputCls} value={p.thickness} min={1} - onChange={(e) => onChange({ thickness: Number(e.target.value) })} + onChange={(e) => onChange({ thickness: clampMin(e.target.value, 1) })} />
diff --git a/src/registry/registry.test.ts b/src/registry/registry.test.ts index 0574a576..bef3af2b 100644 --- a/src/registry/registry.test.ts +++ b/src/registry/registry.test.ts @@ -170,6 +170,46 @@ describe('ellipse.toZPL', () => { }); }); +// ── circle ──────────────────────────────────────────────────────────────────── + +describe('circle.toZPL', () => { + const def = defined(ObjectRegistry['circle']); + + it('emits ^GE with diameter for both axes', () => { + const zpl = def.toZPL(makeObj('circle', { + diameter: 80, thickness: 3, filled: false, color: 'B', + })); + expect(zpl).toContain('^GE80,80,3,B'); + }); + + it('uses diameter as thickness when filled', () => { + const zpl = def.toZPL(makeObj('circle', { + diameter: 80, thickness: 3, filled: true, color: 'B', + })); + expect(zpl).toContain('^GE80,80,80,B'); + }); +}); + +describe('circle.commitTransform', () => { + const def = defined(ObjectRegistry['circle']); + + it('uses the smaller scale axis to keep the circle inside the drag box', () => { + const result = def.commitTransform!( + makeObj('circle', { diameter: 100, thickness: 3, filled: false, color: 'B' }), + { sx: 2, sy: 1.5, snap: (n) => n, nodeHeight: 0, anchor: null }, + ); + expect(result).toEqual({ diameter: 150 }); + }); + + it('clamps the diameter to at least 1', () => { + const result = def.commitTransform!( + makeObj('circle', { diameter: 100, thickness: 3, filled: false, color: 'B' }), + { sx: 0, sy: 0, snap: (n) => n, nodeHeight: 0, anchor: null }, + ); + expect(result).toEqual({ diameter: 1 }); + }); +}); + // ── code128 ─────────────────────────────────────────────────────────────────── describe('code128.toZPL', () => { @@ -352,7 +392,7 @@ describe('ObjectRegistry', () => { const expectedTypes = [ 'text', 'code128', 'code39', 'ean13', 'upca', 'ean8', 'upce', 'interleaved2of5', 'code93', 'qrcode', 'datamatrix', 'pdf417', - 'box', 'ellipse', 'line', 'serial', 'image', + 'box', 'ellipse', 'circle', 'line', 'serial', 'image', ]; it('contains all expected object types', () => { diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index 929d18ff..dc89108f 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -77,6 +77,13 @@ export interface ObjectTypeDefinition

{ * renderer suppresses the text so the designer matches the print output. */ interpretationLocked?: boolean; + /** + * True if the shape requires a 1:1 aspect ratio (e.g. circle: a single + * diameter). The transformer restricts to corner anchors and forces the + * resize bbox to stay square so visual feedback during drag matches the + * uniform `commitTransform` applied on release. + */ + uniformScale?: boolean; toZPL: (obj: LabelObjectBase & { props: P }) => string; /** * Optional hook to enforce type-specific invariants on incoming changes