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 ( ); -} +}); 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 69da3391..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,23 +600,36 @@ 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 // 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, - }; + 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, + }; } }