Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 4 additions & 4 deletions src/components/Canvas/BarcodeObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export function BarcodeObject({
clipY={0}
clipWidth={Math.max(w, 1) + clipLeft + clipRight}
clipHeight={Math.max(h, 1) + textFontSize + textGap}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={(e) =>
e.target.position(snapPos(e.target.x(), e.target.y()))
Expand Down Expand Up @@ -517,7 +517,7 @@ export function BarcodeObject({
id={obj.id}
x={x}
y={y}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={(e) =>
e.target.position(snapPos(e.target.x(), e.target.y()))
Expand Down Expand Up @@ -712,7 +712,7 @@ export function BarcodeObject({
id={obj.id}
x={x}
y={y}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
Expand Down Expand Up @@ -741,7 +741,7 @@ export function BarcodeObject({
id={obj.id}
x={x}
y={y}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
Expand Down
4 changes: 2 additions & 2 deletions src/components/Canvas/ImageObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function ImageObject({
height={h}
stroke={isSelected ? colors.selection : undefined}
strokeWidth={isSelected ? 2 : 0}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
Expand All @@ -107,7 +107,7 @@ export function ImageObject({
id={obj.id}
x={x}
y={y}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
Expand Down
12 changes: 6 additions & 6 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function KonvaObjectInner({
x={x}
y={y}
rotation={zplRotationDeg[p.rotation]}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
Expand Down Expand Up @@ -230,7 +230,7 @@ function KonvaObjectInner({
fill="#000000"
stroke={isSelected ? colors.selection : undefined}
strokeWidth={isSelected ? 1 : 0}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
Expand Down Expand Up @@ -263,7 +263,7 @@ function KonvaObjectInner({
fill="#000000"
stroke={isSelected ? colors.selection : undefined}
strokeWidth={isSelected ? 1 : 0}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
Expand Down Expand Up @@ -342,7 +342,7 @@ function KonvaObjectInner({
id={obj.id}
x={x}
y={y}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
Expand Down Expand Up @@ -410,7 +410,7 @@ function KonvaObjectInner({
}
strokeScaleEnabled={false}
fill={fill}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={(e) => {
// Center-anchored: snap the top-left corner, then re-add radius
Expand Down Expand Up @@ -457,7 +457,7 @@ function KonvaObjectInner({
}
strokeScaleEnabled={false}
fill={fill}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={(e) => {
const snapped = snapPos(e.target.x() - r, e.target.y() - r);
Expand Down
46 changes: 41 additions & 5 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Ruler, RULER_SIZE } from "./Ruler";
import { ObjectRegistry } from "../../registry";
import type { LabelObject } from "../../registry";
import { useColorScheme } from "../../lib/useColorScheme";
import { objectIdsAtPoint } from "./hitTesting";
import { useT } from "../../lib/useT";
import { useCanvasPanZoom } from "./hooks/useCanvasPanZoom";
import { useCanvasLasso } from "./hooks/useCanvasLasso";
Expand Down Expand Up @@ -182,7 +183,8 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
updateObjects(
ids.flatMap((sid) => {
const obj = objs.find((o) => o.id === sid);
return obj ? [{ id: sid, changes: { x: obj.x + dx, y: obj.y + dy } }] : [];
if (!obj || obj.locked) return [];
return [{ id: sid, changes: { x: obj.x + dx, y: obj.y + dy } }];
}),
);
};
Expand Down Expand Up @@ -535,6 +537,38 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
if (e.target === e.target.getStage()) selectObjects([]);
};

/**
* Click-passthrough for locked objects (Figma idiom). The locked node
* still listens (so the Alt+click cycle keeps reaching it), but its
* onSelect routes here instead of selecting itself. The next non-locked
* hit at the same point wins; if nothing's left, treat as background.
*
* Pointer source is `lastPointerRef` rather than the original click
* event because `onSelect` deliberately drops the event to keep the
* KonvaObjectProps surface narrow. The document-level pointermove
* listener updates the ref every frame, so by the time the click
* handler fires the ref is at the click position.
*/
const handleLockedClick = (add: boolean) => {
const stage = stageRef.current;
const cr = containerRef.current?.getBoundingClientRect();
if (!stage || !cr) return;
const point = {
x: lastPointerRef.current.x - cr.left,
y: lastPointerRef.current.y - cr.top,
};
const nonLocked = new Set(
getCurrentObjects().flatMap((o) => o.locked ? [] : [o.id]),
);
const hit = objectIdsAtPoint(stage, point, nonLocked)[0];
if (hit) {
if (add) toggleSelectObject(hit);
else selectObject(hit);
return;
}
if (!add) selectObjects([]);
};

const handleMouseMove = (e: React.MouseEvent) => {
onPanMouseMove(e);
onLassoMouseMove(e);
Expand Down Expand Up @@ -737,7 +771,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
/>
)}

{objects.map((obj) => (
{objects.map((obj) => obj.visible === false ? null : (
<KonvaObject
key={obj.id}
obj={obj}
Expand All @@ -746,9 +780,11 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
offsetX={objectsOffsetX}
offsetY={labelOffsetY}
isSelected={selectedIds.includes(obj.id)}
onSelect={(add) =>
add ? toggleSelectObject(obj.id) : selectObject(obj.id)
}
onSelect={(add) => {
if (obj.locked) handleLockedClick(add);
else if (add) toggleSelectObject(obj.id);
else selectObject(obj.id);
}}
onChange={(changes) => handleObjectChange(obj.id, changes)}
snap={snap}
getOthersSnapshot={snapEnabled ? undefined : getOthersSnapshot}
Expand Down
8 changes: 4 additions & 4 deletions src/components/Canvas/LineObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ export function LineObject({
]}
stroke="transparent"
strokeWidth={Math.max(lineStrokeWidth, 14)}
draggable
draggable={!obj.locked}
{...selectionHandlers(onSelect)}
onDragMove={(e) => {
// Snap the absolute start-point position to the grid (not
Expand Down Expand Up @@ -418,7 +418,7 @@ export function LineObject({
width={HANDLE_HIT_SIZE}
height={HANDLE_HIT_SIZE}
fill="transparent"
draggable
draggable={!obj.locked}
onDragMove={(e) => {
const endDotX = pxToDots(x2 - offsetX, scale, dpmm);
const endDotY = pxToDots(y2 - offsetY, scale, dpmm);
Expand Down Expand Up @@ -483,7 +483,7 @@ export function LineObject({
width={HANDLE_HIT_SIZE}
height={HANDLE_HIT_SIZE}
fill="transparent"
draggable
draggable={!obj.locked}
onDragMove={(e) => {
const r = endpointDrag(
e.target.x() + HANDLE_HIT_SIZE / 2,
Expand Down Expand Up @@ -542,7 +542,7 @@ export function LineObject({
width={HANDLE_HIT_SIZE}
height={HANDLE_HIT_SIZE}
fill="transparent"
draggable
draggable={!obj.locked}
onDragMove={(e) => {
const cursorX = e.target.x() + HANDLE_HIT_SIZE / 2;
const cursorY = e.target.y() + HANDLE_HIT_SIZE / 2;
Expand Down
36 changes: 36 additions & 0 deletions src/components/Canvas/hitTesting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type Konva from "konva";

/**
* Resolve the registered object ids at a stage-relative point, top first.
*
* `stage.getAllIntersections` returns Konva nodes in z-order (front first),
* which may be child shapes of a registered object Group rather than the
* Group itself — every `KonvaObject` puts `id={obj.id}` on the *outer*
* Group. So we walk each hit upward until we land on a candidate id, dedupe,
* and emit in the same z-order Konva produced.
*
* Used by:
* - Alt+click cycle: cycles through every overlapping object at a point.
* - Click-passthrough for locked objects: finds the next non-locked hit.
*/
export function objectIdsAtPoint(
stage: Konva.Stage,
point: { x: number; y: number },
candidates: ReadonlySet<string>,
): string[] {
const hits: string[] = [];
const seen = new Set<string>();
for (const shape of stage.getAllIntersections(point)) {
let n: Konva.Node | null = shape;
while (n) {
const id = n.id();
if (id && candidates.has(id) && !seen.has(id)) {
hits.push(id);
seen.add(id);
break;
}
n = n.getParent();
}
}
return hits;
}
27 changes: 5 additions & 22 deletions src/components/Canvas/hooks/useAltClickCycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
nextCycleIndex,
type CycleAnchor,
} from "../altClickCycle";
import { objectIdsAtPoint } from "../hitTesting";

interface Options {
containerRef: React.RefObject<HTMLDivElement | null>;
Expand Down Expand Up @@ -34,29 +35,11 @@ export function useAltClickCycle({ containerRef, stageRef, selectObject }: Optio
if (!stage) return;
const rect = el.getBoundingClientRect();
const point = { x: e.clientX - rect.left, y: e.clientY - rect.top };
// Use Konva's own hit-graph: it accounts for view rotation, pan
// offset, per-shape transforms and the listening flag, all of
// which our own bbox math would have to mirror by hand.
const intersections = stage.getAllIntersections(point);
if (intersections.length === 0) return;
// Konva's own hit-graph respects view rotation, pan offset,
// per-shape transforms and the listening flag — far cheaper than
// mirroring all of that in our own bbox math.
const objIds = new Set(getCurrentObjects().map((o) => o.id));
const hits: string[] = [];
const seen = new Set<string>();
for (const shape of intersections) {
// Walk up to the registered object Group (each KonvaObject sets
// `id={obj.id}` on its outer Group; intersections may land on a
// child Rect/Text/etc.).
let n: Konva.Node | null = shape;
while (n) {
const id = n.id();
if (id && objIds.has(id) && !seen.has(id)) {
hits.push(id);
seen.add(id);
break;
}
n = n.getParent();
}
}
const hits = objectIdsAtPoint(stage, point, objIds);
if (hits.length === 0) return;
e.stopPropagation();
e.preventDefault();
Expand Down
6 changes: 5 additions & 1 deletion src/components/Canvas/hooks/useCanvasLasso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export function useCanvasLasso({ containerRef, stageRef, spaceDown, selectObject
lassoRectRef.current = null;
setLasso(null);
if (!rect || !stageRef.current) return;
const ids = getCurrentObjects().map((o) => o.id);
// Figma-style: locked objects opt out of lasso selection — they can't
// be moved or transformed, so grabbing them into a marquee selection
// would make the post-lasso drag feel dead. Direct click and the
// LayersPanel still target locked items, so bulk-unlock stays possible.
const ids = getCurrentObjects().flatMap((o) => o.locked ? [] : [o.id]);
selectObjects(getIdsIntersectingRect(stageRef.current, ids, rect));
};

Expand Down
17 changes: 10 additions & 7 deletions src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,17 @@ export function useKonvaTransformer({
}
if (selectedIds.length === 1) {
const selectedObj = objects.find((o) => o.id === selectedIds[0]);
const useTransformer = selectedObj && selectedObj.type !== "line";
const useTransformer =
selectedObj && selectedObj.type !== "line" && !selectedObj.locked;
const node = useTransformer
? stageRef.current.findOne<Konva.Node>(`#${selectedIds[0]}`)
: null;
transformerRef.current.nodes(node ? [node] : []);
} else {
const nodes = selectedIds
.filter((id) => objects.find((o) => o.id === id)?.type !== "line")
.map((id) => stageRef.current?.findOne<Konva.Node>(`#${id}`))
.map((id) => objects.find((o) => o.id === id))
.filter((o): o is LabelObject => !!o && o.type !== "line" && !o.locked)
.map((o) => stageRef.current?.findOne<Konva.Node>(`#${o.id}`))
.filter((n): n is Konva.Node => n != null);
transformerRef.current.nodes(nodes);
}
Expand All @@ -178,11 +180,12 @@ export function useKonvaTransformer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, selectedTypesKey, selectedSignature, stageRef, transformerRef]);

const resizeEnabled = selectedIds.length <= 1;
const singleType =
const singleSelected =
selectedIds.length === 1
? objects.find((o) => o.id === selectedIds[0])?.type ?? ""
: "";
? objects.find((o) => o.id === selectedIds[0])
: undefined;
const resizeEnabled = selectedIds.length <= 1 && !singleSelected?.locked;
const singleType = singleSelected?.type ?? "";
const isUniformScale = !!ObjectRegistry[singleType]?.uniformScale;
const enabledAnchors: string[] | undefined =
selectedIds.length > 1
Expand Down
Loading