Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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