From bdf6fa481d341eeb8c4ba6cc0c9a2cd3deb38b7b Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 11 May 2026 22:10:52 +0200 Subject: [PATCH 1/3] fix(canvas): crop GS1 DataBar paddingheight on correct axis after rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bwip-js paddingheight=2 rows sit on top and bottom of the *upright* bitmap. After bwip rotates the symbol (rotate='R' or 'L' for ZPL B), those rows end up on the left and right edges of the rotated bitmap, so the existing y-axis crop was slicing into the bar pattern rather than the padding — leaving visible gaps between the bars and the bbox in canvas rendering. Now the crop axis follows the rotation: y for N / I (padding stays top/bottom) and x for R / B (padding moved to left/right). --- src/components/Canvas/bwipHelpers.ts | 30 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index 69da3391..bf2ccab6 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -605,17 +605,33 @@ export function getDisplaySize( // than the spec-correct height. Zebra firmware fills the full reserved // height with bars; mirror that by cropping the source bitmap to the // bar-only rows. + // + // Rotation flips which axis the padding sits on. For N / I the padding + // is on top/bottom of the bitmap as bwip produced it; for R / B (bwip + // rotated 90° CW / CCW respectively) the same rows end up on the + // left/right edges, so the crop must run along the x-axis instead. let bitmapCrop: BarcodeDisplaySize["bitmapCrop"]; if (obj.type === "gs1databar") { const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); const padPx = 2 * bwipSc; // paddingheight=2 × bwip scale per side - if (canvas.height > 2 * padPx) { - bitmapCrop = { - x: 0, - y: padPx, - width: canvas.width, - height: canvas.height - 2 * padPx, - }; + if (isQuarter) { + if (canvas.width > 2 * padPx) { + bitmapCrop = { + x: padPx, + y: 0, + width: canvas.width - 2 * padPx, + height: canvas.height, + }; + } + } else { + if (canvas.height > 2 * padPx) { + bitmapCrop = { + x: 0, + y: padPx, + width: canvas.width, + height: canvas.height - 2 * padPx, + }; + } } } From 9fd3765e30a7ec7db70564995dd54ed5b2fce93b Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 11 May 2026 22:17:02 +0200 Subject: [PATCH 2/3] fix(canvas): rotate button tracks selected node during drag and transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the floating 90 deg quick-rotate button: 1. Hook dragmove and transform (not just dragstart / dragend) so the anchor recomputes per frame instead of staying frozen until the interaction ends — drops the isInteracting hide that was masking the lag with a frame of invisibility. 2. Update the button's Konva node imperatively through a ref in addition to setState. The React state path adds a frame of latency relative to Konva's native drag updates, which showed as a small wobble. Writing the position straight to Konva keeps both painters on the same frame; the React state stays in sync so non-drag re-renders start from the latest position. --- src/components/Canvas/LabelCanvas.tsx | 41 ++++++++++++------------ src/components/Canvas/RotationButton.tsx | 10 ++++-- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 5a338e7b..8c88805a 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -394,21 +394,7 @@ export const LabelCanvas = forwardRef(function LabelCa : null; const stepRotation = singleSelected ? getStepRotation(singleSelected) : null; const [rotationBtnPos, setRotationBtnPos] = useState<{ x: number; y: number } | null>(null); - // Hide the rotate affordance during an active drag / transform — the - // button's position is React-state-driven and would otherwise lag behind - // the live Konva node until the interaction ends. - const [isInteracting, setIsInteracting] = useState(false); - useEffect(() => { - const stage = stageRef.current; - if (!stage) return; - const start = () => setIsInteracting(true); - const end = () => setIsInteracting(false); - stage.on("dragstart.rotbtn transformstart.rotbtn", start); - stage.on("dragend.rotbtn transformend.rotbtn", end); - return () => { - stage.off("dragstart.rotbtn transformstart.rotbtn dragend.rotbtn transformend.rotbtn"); - }; - }, []); + const rotationBtnRef = useRef(null); useLayoutEffect(() => { if (!singleSelected || !stepRotation) { setRotationBtnPos(null); @@ -421,11 +407,23 @@ export const LabelCanvas = forwardRef(function LabelCa setRotationBtnPos(null); return; } - const rect = node.getClientRect({ relativeTo: stage, skipStroke: true }); - setRotationBtnPos({ - x: rect.x + rect.width + ROTATE_BUTTON_GAP_PX, - y: rect.y + ROTATE_BUTTON_TOP_OFFSET_PX, - }); + // Recompute the button anchor whenever the selected node moves. + // The handler updates the Konva node directly so the button tracks + // the drag at full frame rate; React state is kept in sync so a + // subsequent re-render (selection change, scale, etc.) starts from + // the latest position instead of snapping back. + const update = () => { + const rect = node.getClientRect({ relativeTo: stage, skipStroke: true }); + const x = rect.x + rect.width + ROTATE_BUTTON_GAP_PX; + const y = rect.y + ROTATE_BUTTON_TOP_OFFSET_PX; + rotationBtnRef.current?.position({ x, y }); + setRotationBtnPos({ x, y }); + }; + update(); + stage.on("dragmove.rotbtn transform.rotbtn", update); + return () => { + stage.off("dragmove.rotbtn transform.rotbtn"); + }; }, [singleSelected, stepRotation, scale, viewRotation]); const handleRotateStep = () => { @@ -819,8 +817,9 @@ export const LabelCanvas = forwardRef(function LabelCa ignoreStroke /> - {rotationBtnPos && !isInteracting && ( + {rotationBtnPos && ( (function RotationButton( + { x, y, color, onClick }, + ref, +) { const [hover, setHover] = useState(false); // Track the stage we set a cursor on so unmount-while-hovering can still // clean up (onMouseLeave never fires in that case — e.g. user hits Delete @@ -61,6 +64,7 @@ export function RotationButton({ x, y, color, onClick }: Props) { return ( ); -} +}); From 42051bde46f05dfeb090eaeb8f79d7e76daf2312 Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 11 May 2026 22:19:27 +0200 Subject: [PATCH 3/3] refactor(bwip): extract GS1 padding constant, collapse crop branches Two clean-up sweeps after the audit: 1. The bwip option (paddingheight: 2) and the matching bitmap-crop logic both hard-coded the row count, so a change to one would silently desync the other. Lifted the value into bwipConstants.ts as GS1_DATABAR_PADDING_ROWS and reused at both sites. 2. The rotation-axis-aware crop split into mirror-image if/else branches around isQuarter. Single guard, single ternary for the axis-specific shape keeps the intent visible without the duplicated check. --- src/components/Canvas/bwipConstants.ts | 6 ++++ src/components/Canvas/bwipHelpers.ts | 40 ++++++++++++-------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/components/Canvas/bwipConstants.ts b/src/components/Canvas/bwipConstants.ts index f4ac73fb..6f854032 100644 --- a/src/components/Canvas/bwipConstants.ts +++ b/src/components/Canvas/bwipConstants.ts @@ -73,6 +73,12 @@ export const GS1_DATABAR_SPEC_HEIGHT_MODULES: Partial< 6: 34, }; +/** Rows of whitespace bwip adds top and bottom of the GS1 DataBar bar + * pattern when buildBwipOptions sets `paddingheight: N`. Re-used by + * the bitmap-crop logic so the bar-extraction stays in lockstep with + * the bwip option above. */ +export const GS1_DATABAR_PADDING_ROWS = 2; + export const EAN_UPC_TYPES = new Set([ "ean13", "ean8", diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index bf2ccab6..daeafe83 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -26,6 +26,7 @@ import { CODE11_QUIET_ZONE_DELTA_MODULES, CODE93_QUIET_ZONE_DELTA_MODULES, EAN_TEXT_ZONE_DOTS, + GS1_DATABAR_PADDING_ROWS, GS1_DATABAR_SPEC_HEIGHT_MODULES, LOGMARS_TEXT_ZONE_DOTS, MICROPDF417_QUIET_ZONE_ROWS, @@ -407,7 +408,7 @@ export function buildBwipOptions( text, scale, height: 10, - paddingheight: 2, + paddingheight: GS1_DATABAR_PADDING_ROWS, ...(sym === 7 ? { segments: p.segments ?? GS1_DATABAR_DEFAULT_SEGMENTS } : {}), }; break; @@ -599,7 +600,7 @@ export function getDisplaySize( } } - // GS1 DataBar opts include `paddingheight: 2`, which adds whitespace + // GS1 DataBar opts include `paddingheight: N`, which adds whitespace // rows on top and bottom of the bwip canvas. Without cropping them out, // the bitmap drawn at displayH leaves the bars proportionally shorter // than the spec-correct height. Zebra firmware fills the full reserved @@ -613,25 +614,22 @@ export function getDisplaySize( let bitmapCrop: BarcodeDisplaySize["bitmapCrop"]; if (obj.type === "gs1databar") { const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); - const padPx = 2 * bwipSc; // paddingheight=2 × bwip scale per side - if (isQuarter) { - if (canvas.width > 2 * padPx) { - bitmapCrop = { - x: padPx, - y: 0, - width: canvas.width - 2 * padPx, - height: canvas.height, - }; - } - } else { - if (canvas.height > 2 * padPx) { - bitmapCrop = { - x: 0, - y: padPx, - width: canvas.width, - height: canvas.height - 2 * padPx, - }; - } + const padPx = GS1_DATABAR_PADDING_ROWS * bwipSc; + const axisDim = isQuarter ? canvas.width : canvas.height; + if (axisDim > 2 * padPx) { + bitmapCrop = isQuarter + ? { + x: padPx, + y: 0, + width: canvas.width - 2 * padPx, + height: canvas.height, + } + : { + x: 0, + y: padPx, + width: canvas.width, + height: canvas.height - 2 * padPx, + }; } }