Skip to content

Commit e60350e

Browse files
committed
Merge branch 'main' into prod
2 parents b3c0e18 + 599ce06 commit e60350e

66 files changed

Lines changed: 2577 additions & 191 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/Canvas/BarcodeObject.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import {
1515
get1DBwipScale,
1616
getEanUpcHriFragments,
1717
renderEanUpcRawCanvas,
18-
resolveHriAbove,
1918
renderTlc39Canvas,
2019
type BarcodeDisplaySize,
2120
type EanUpcType,
2221
} from "./bwipHelpers";
22+
import { resolveHriAbove } from "../../lib/barcodeHri";
2323
import { objectRotation } from "../../registry/rotation";
2424
import { rotatedGroupTransform } from "./rotatedGroupTransform";
2525
import { buildEanUpcDigitOverlay } from "./eanUpcDigitNodes";
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
2+
import { createPortal } from "react-dom";
3+
import {
4+
ChevronRightIcon,
5+
ChevronUpIcon,
6+
ChevronDownIcon,
7+
ChevronDoubleUpIcon,
8+
ChevronDoubleDownIcon,
9+
Square2StackIcon,
10+
ScissorsIcon,
11+
DocumentDuplicateIcon,
12+
ClipboardIcon,
13+
TrashIcon,
14+
RectangleGroupIcon,
15+
RectangleStackIcon,
16+
LockClosedIcon,
17+
LockOpenIcon,
18+
CodeBracketIcon,
19+
PhotoIcon,
20+
ArrowDownTrayIcon,
21+
PlusIcon,
22+
Squares2X2Icon,
23+
} from "@heroicons/react/16/solid";
24+
import type { MenuAction, MenuSection } from "./canvasActions";
25+
26+
type IconType = typeof ChevronRightIcon;
27+
28+
// Icons stay in the view, keyed by the action id, so canvasActions stays pure.
29+
const ICONS: Record<string, IconType> = {
30+
copy: Square2StackIcon,
31+
cut: ScissorsIcon,
32+
duplicate: DocumentDuplicateIcon,
33+
pasteHere: ClipboardIcon,
34+
delete: TrashIcon,
35+
toFront: ChevronDoubleUpIcon,
36+
forward: ChevronUpIcon,
37+
backward: ChevronDownIcon,
38+
toBack: ChevronDoubleDownIcon,
39+
group: RectangleGroupIcon,
40+
ungroup: RectangleStackIcon,
41+
copyZplSelected: CodeBracketIcon,
42+
copyZplLabel: CodeBracketIcon,
43+
copyImage: PhotoIcon,
44+
exportImage: ArrowDownTrayIcon,
45+
addHere: PlusIcon,
46+
selectAll: Squares2X2Icon,
47+
};
48+
49+
function iconFor(item: MenuAction): IconType | undefined {
50+
// The builder flips the lock row's labelKey to reflect current state.
51+
if (item.id === "lock") return item.labelKey === "unlock" ? LockOpenIcon : LockClosedIcon;
52+
return ICONS[item.id];
53+
}
54+
55+
function RowIcon({ icon: Icon }: { icon?: IconType }) {
56+
return Icon ? <Icon className="w-4 h-4 shrink-0" /> : null;
57+
}
58+
59+
interface Props {
60+
sections: MenuSection[];
61+
/** Viewport coords of the right-click. */
62+
x: number;
63+
y: number;
64+
/** Resolved labels for `labelKey` (t.contextMenu). */
65+
labels: Record<string, string>;
66+
onClose: () => void;
67+
}
68+
69+
const MENU_BOX =
70+
"z-[60] min-w-44 bg-surface border border-border rounded-lg shadow-2xl p-1";
71+
72+
function labelOf(item: MenuAction, labels: Record<string, string>): string {
73+
return item.label ?? (item.labelKey ? labels[item.labelKey] ?? item.labelKey : item.id);
74+
}
75+
76+
function Row({
77+
item,
78+
labels,
79+
onClose,
80+
}: {
81+
item: MenuAction;
82+
labels: Record<string, string>;
83+
onClose: () => void;
84+
}) {
85+
const [subOpen, setSubOpen] = useState(false);
86+
const wrapRef = useRef<HTMLDivElement>(null);
87+
const subRef = useRef<HTMLDivElement>(null);
88+
// Submenus open to the right by default; flip left and/or shift up once the
89+
// real size is known so a nested level can't run off the viewport.
90+
const [flip, setFlip] = useState<{ left: boolean; up: number }>({ left: false, up: 0 });
91+
const cls = `w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs font-mono rounded transition-colors ${
92+
item.disabled
93+
? "text-muted/40 cursor-not-allowed"
94+
: item.danger
95+
? "text-text hover:bg-error/15 hover:text-error"
96+
: "text-text hover:bg-surface-2"
97+
}`;
98+
99+
useLayoutEffect(() => {
100+
if (!subOpen) return;
101+
const wrap = wrapRef.current;
102+
const sub = subRef.current;
103+
if (!wrap || !sub) return;
104+
const wr = wrap.getBoundingClientRect();
105+
const sr = sub.getBoundingClientRect();
106+
const overBottom = sr.bottom + 4 - window.innerHeight;
107+
setFlip({
108+
left: wr.right + sr.width + 4 > window.innerWidth,
109+
up: overBottom > 0 ? overBottom : 0,
110+
});
111+
}, [subOpen]);
112+
113+
const submenu = item.submenu;
114+
if (submenu?.length) {
115+
return (
116+
<div
117+
ref={wrapRef}
118+
className="relative"
119+
onMouseEnter={() => !item.disabled && setSubOpen(true)}
120+
onMouseLeave={() => setSubOpen(false)}
121+
>
122+
<button disabled={item.disabled} className={cls}>
123+
<RowIcon icon={iconFor(item)} />
124+
<span className="flex-1">{labelOf(item, labels)}</span>
125+
<ChevronRightIcon className="w-3.5 h-3.5 text-muted shrink-0" />
126+
</button>
127+
{subOpen && (
128+
<div
129+
ref={subRef}
130+
className={`absolute top-0 ${flip.left ? "right-full mr-0.5" : "left-full ml-0.5"} ${MENU_BOX}`}
131+
style={flip.up ? { transform: `translateY(-${flip.up}px)` } : undefined}
132+
>
133+
{submenu.map((s) => (
134+
<Row key={s.id} item={s} labels={labels} onClose={onClose} />
135+
))}
136+
</div>
137+
)}
138+
</div>
139+
);
140+
}
141+
142+
return (
143+
<button
144+
disabled={item.disabled}
145+
className={cls}
146+
onClick={() => {
147+
if (item.disabled) return;
148+
item.run?.();
149+
onClose();
150+
}}
151+
>
152+
<RowIcon icon={iconFor(item)} />
153+
<span className="flex-1">{labelOf(item, labels)}</span>
154+
</button>
155+
);
156+
}
157+
158+
export function CanvasContextMenu({ sections, x, y, labels, onClose }: Props) {
159+
const ref = useRef<HTMLDivElement>(null);
160+
const [pos, setPos] = useState({ x, y });
161+
162+
// Clamp into the viewport once the real size is known.
163+
useLayoutEffect(() => {
164+
const el = ref.current;
165+
if (!el) return;
166+
const r = el.getBoundingClientRect();
167+
setPos({
168+
x: Math.min(x, window.innerWidth - r.width - 4),
169+
y: Math.min(y, window.innerHeight - r.height - 4),
170+
});
171+
}, [x, y]);
172+
173+
useEffect(() => {
174+
const onPointer = (e: PointerEvent) => {
175+
if (!ref.current?.contains(e.target as Node)) onClose();
176+
};
177+
const onKey = (e: KeyboardEvent) => {
178+
if (e.key === "Escape") onClose();
179+
};
180+
// Defer so the opening right-click's own pointer events don't self-close.
181+
const id = window.setTimeout(() => {
182+
window.addEventListener("pointerdown", onPointer);
183+
window.addEventListener("keydown", onKey);
184+
}, 0);
185+
return () => {
186+
window.clearTimeout(id);
187+
window.removeEventListener("pointerdown", onPointer);
188+
window.removeEventListener("keydown", onKey);
189+
};
190+
}, [onClose]);
191+
192+
return createPortal(
193+
<div ref={ref} className={`fixed ${MENU_BOX}`} style={{ left: pos.x, top: pos.y }} role="menu">
194+
{sections.map((section, i) => (
195+
<div key={section.id}>
196+
{i > 0 && <div className="my-1 border-t border-border" />}
197+
{section.items.map((item) => (
198+
<Row key={item.id} item={item} labels={labels} onClose={onClose} />
199+
))}
200+
</div>
201+
))}
202+
</div>,
203+
document.body,
204+
);
205+
}

‎src/components/Canvas/KonvaObject.tsx‎

Lines changed: 41 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useEffect } from "react";
2-
import type Konva from "konva";
32
import { useFontCacheVersion } from "../../hooks/useFontCacheVersion";
43
import { Ellipse, Group, Rect, Shape, Text } from "react-konva";
54
import { lookupBoundVariable, shouldShowFallbackTint } from "../../lib/variableBinding";
@@ -15,7 +14,7 @@ import { useLabelStore } from "../../store/labelStore";
1514
import { applyBindingToObject, buildActiveCsvRow, clockCtxFromLabel } from "../../lib/variableBinding";
1615
import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "../../lib/labelGeometry/textPositionTransforms";
1716
import { getTextRenderMetrics } from "../../lib/labelGeometry/textRenderMetrics";
18-
import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";
17+
import { selectionHandlers, CAPTURE_CHROME, type KonvaObjectProps } from "./konvaObjectProps";
1918
import { setMeasuredBounds, clearMeasuredBounds } from "./measuredBoundsCache";
2019
import { DEFAULT_GS_SYMBOL_META, GS_SYMBOLS } from "../../registry/symbol";
2120
import { GS_SYMBOL_PATHS, GS_VECTOR_CODES, type GsVectorCode } from "../../registry/gsSymbolPaths";
@@ -148,6 +147,9 @@ function PlaceholderRect({
148147
);
149148
}
150149

150+
/** Opacity of the ^TB overset ghost (the clipped-off value shown for editing). */
151+
const TB_OVERSET_GHOST_OPACITY = 0.3;
152+
151153
function TextFieldContent({
152154
obj,
153155
content,
@@ -156,6 +158,7 @@ function TextFieldContent({
156158
dpmm,
157159
fontVersion,
158160
placeholderColor,
161+
isSelected = false,
159162
}: {
160163
obj: TextFieldObj;
161164
content: string;
@@ -164,6 +167,7 @@ function TextFieldContent({
164167
dpmm: number;
165168
fontVersion: number;
166169
placeholderColor: string;
170+
isSelected?: boolean;
167171
}) {
168172
const shift = base.offsetXPx ?? 0;
169173
const shiftY = base.offsetYPx ?? 0;
@@ -229,36 +233,6 @@ function TextFieldContent({
229233
/>
230234
);
231235
}
232-
// Konva's getClientRect ignores a Group's clip and returns the full children
233-
// bounds, which would inflate the Transformer box past the ^TB clip. Report
234-
// the clip rect instead (the actual visible region) so the box matches.
235-
const tbClipRef = (node: Konva.Group | null) => {
236-
if (!node) return;
237-
node.getClientRect = (config?: { skipTransform?: boolean; relativeTo?: Konva.Container }) => {
238-
const local = {
239-
x: node.clipX() || 0,
240-
y: node.clipY() || 0,
241-
width: node.clipWidth(),
242-
height: node.clipHeight(),
243-
};
244-
if (config?.skipTransform) return local;
245-
const tr = config?.relativeTo
246-
? node.getAbsoluteTransform(config.relativeTo)
247-
: node.getAbsoluteTransform();
248-
const corners = [
249-
{ x: local.x, y: local.y },
250-
{ x: local.x + local.width, y: local.y },
251-
{ x: local.x + local.width, y: local.y + local.height },
252-
{ x: local.x, y: local.y + local.height },
253-
].map((p) => tr.point(p));
254-
const xs = corners.map((c) => c.x);
255-
const ys = corners.map((c) => c.y);
256-
const minX = Math.min(...xs);
257-
const minY = Math.min(...ys);
258-
return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
259-
};
260-
};
261-
262236
// ^TB: word-wrap left-aligned, no spacing/indent/justify, clipped at the
263237
// block height (Labelary truncates mid-glyph, so clip rather than drop whole
264238
// lines). At 90deg rotations the block rect stays axis-aligned.
@@ -271,27 +245,41 @@ function TextFieldContent({
271245
const clip = tbBoundsDots(blockWidth, blockHeight ?? fontHeight, obj.props.rotation);
272246
// Lines sit exactly like ^FB (first line at the block top with Konva's cap
273247
// pad); matches the Labelary preview so toggling it doesn't shift the text.
248+
const tbLineNodes = (extra?: Partial<BaseTextProps>) =>
249+
tbLines.map((line, i) => {
250+
const startDots = blockLineStartDots(i, obj.props.rotation, 0, tbStep);
251+
return (
252+
<Text
253+
key={`${fontVersion}-${i}`}
254+
x={dotsToPx(startDots.x, scale, dpmm) + shift}
255+
y={dotsToPx(startDots.y, scale, dpmm) + shiftY}
256+
text={line}
257+
{...base}
258+
{...extra}
259+
/>
260+
);
261+
});
274262
return (
275-
<Group
276-
ref={tbClipRef}
277-
clipX={dotsToPx(clip.x, scale, dpmm) + shift}
278-
clipY={dotsToPx(clip.y, scale, dpmm) + shiftY}
279-
clipWidth={dotsToPx(clip.width, scale, dpmm)}
280-
clipHeight={dotsToPx(clip.height, scale, dpmm)}
281-
>
282-
{tbLines.map((line, i) => {
283-
const startDots = blockLineStartDots(i, obj.props.rotation, 0, tbStep);
284-
return (
285-
<Text
286-
key={`${fontVersion}-${i}`}
287-
x={dotsToPx(startDots.x, scale, dpmm) + shift}
288-
y={dotsToPx(startDots.y, scale, dpmm) + shiftY}
289-
text={line}
290-
{...base}
291-
/>
292-
);
293-
})}
294-
</Group>
263+
<>
264+
{/* Overset ghost: the full value including the part the box clips,
265+
dimmed and editor-only, so the hidden text stays visible and the
266+
transformer (whole-text bounds) can still scale it. */}
267+
{isSelected && (
268+
<Group name={CAPTURE_CHROME} listening={false} opacity={TB_OVERSET_GHOST_OPACITY}>
269+
{tbLineNodes({ stroke: undefined, strokeWidth: 0 })}
270+
</Group>
271+
)}
272+
{/* Printing text, clipped to the ^TB box. getClientRect ignores the
273+
clip, so the selection bounds stay the full text (scalable). */}
274+
<Group
275+
clipX={dotsToPx(clip.x, scale, dpmm) + shift}
276+
clipY={dotsToPx(clip.y, scale, dpmm) + shiftY}
277+
clipWidth={dotsToPx(clip.width, scale, dpmm)}
278+
clipHeight={dotsToPx(clip.height, scale, dpmm)}
279+
>
280+
{tbLineNodes()}
281+
</Group>
282+
</>
295283
);
296284
}
297285
const justify = blockJustify ?? "L";
@@ -767,6 +755,7 @@ function KonvaObjectInner({
767755
scale={scale}
768756
dpmm={dpmm}
769757
fontVersion={fontVersion}
758+
isSelected={isSelected}
770759
/>
771760
{obj.type === "text" && isSelected && obj.props.blockWidth && (
772761
<BlockWrapGuide

0 commit comments

Comments
 (0)