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
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@ export function useCanvasInteractionHandlers({
}: UseCanvasInteractionHandlersOptions) {
const onContainerMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.button === 1) {
e.preventDefault();
startPan(e.clientX, e.clientY);
} else if (e.button === 0 && spaceRef.current) {
const isPanButton = e.button === 1 || e.button === 2;
if (isPanButton || (e.button === 0 && spaceRef.current)) {
e.preventDefault();
startPan(e.clientX, e.clientY);
}
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/features/canvas/hooks/useGroupOBB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export function useGroupOBB(
const [persistentGroupOBB, setPersistentGroupOBB] =
useState<PersistentGroupOBB | null>(null);

const isNonPrimaryButton = (button: number | undefined) =>
button !== undefined && button !== 0;

// Ref mirror so gesture callbacks can read the latest value without deps.
const persistentGroupOBBRef = useRef(persistentGroupOBB);
persistentGroupOBBRef.current = persistentGroupOBB;
Expand Down Expand Up @@ -81,6 +84,7 @@ export function useGroupOBB(

const onGroupHandleMouseDown = useCallback(
(e: React.MouseEvent<SVGCircleElement>, handle: HandlePos) => {
if (isNonPrimaryButton(e.button)) return;
e.stopPropagation();
useCanvasStore.getState().snapshotForGesture();
const state = useCanvasStore.getState();
Expand Down Expand Up @@ -157,6 +161,7 @@ export function useGroupOBB(
gHW: number,
gHH: number,
) => {
if (isNonPrimaryButton(e.button)) return;
e.stopPropagation();
useCanvasStore.getState().snapshotForGesture();
const state = useCanvasStore.getState();
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/features/canvas/hooks/useObjectDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ export function useObjectDrag(
const [dragging, setDragging] = useState<DraggingState | null>(null);
const justDraggedRef = useRef(false);

const isNonPrimaryButton = (button: number | undefined) =>
button !== undefined && button !== 0;

const onImportMouseDown = useCallback(
(e: React.MouseEvent, id: string) => {
if (isNonPrimaryButton(e.button)) return;
if (spaceRef.current) return; // space held → pan mode, not drag
e.stopPropagation();
const state = useCanvasStore.getState();
Expand Down Expand Up @@ -82,6 +86,7 @@ export function useObjectDrag(

const onGroupMouseDown = useCallback(
(e: React.MouseEvent<SVGRectElement>) => {
if (isNonPrimaryButton(e.button)) return;
if (spaceRef.current) return;
e.stopPropagation();
useCanvasStore.getState().snapshotForGesture();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ export function useObjectScaleRotate(
const [scaling, setScaling] = useState<ScalingState | null>(null);
const [rotating, setRotating] = useState<RotatingState | null>(null);

const isNonPrimaryButton = (button: number | undefined) =>
button !== undefined && button !== 0;

const onHandleMouseDown = useCallback(
(e: React.MouseEvent<SVGCircleElement>, id: string, handle: HandlePos) => {
if (isNonPrimaryButton(e.button)) return;
e.stopPropagation();
useCanvasStore.getState().snapshotForGesture();
const imp = useCanvasStore.getState().imports.find((i) => i.id === id);
Expand Down Expand Up @@ -58,6 +62,7 @@ export function useObjectScaleRotate(
cxSvg: number,
cySvg: number,
) => {
if (isNonPrimaryButton(e.button)) return;
e.stopPropagation();
useCanvasStore.getState().snapshotForGesture();
const imp = useCanvasStore.getState().imports.find((i) => i.id === id);
Expand Down
86 changes: 64 additions & 22 deletions tests/component/PlotCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ beforeEach(() => {
});

describe("PlotCanvas", () => {
const setupRO = () => {
const Original = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
_cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this._cb = cb;
}
observe(el: Element) {
this._cb(
[{ contentRect: { width: 800, height: 600 } } as ResizeObserverEntry],
this as unknown as ResizeObserver,
);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
return () => {
globalThis.ResizeObserver = Original;
};
};

// ── Basic rendering ─────────────────────────────────────────────────

it("renders without crashing", () => {
Expand Down Expand Up @@ -473,12 +494,54 @@ describe("PlotCanvas", () => {
// ── Middle-click pan ───────────────────────────────────────────────

it("middle-click on container starts pan (does not crash)", () => {
const restore = setupRO();
const { container } = render(<PlotCanvas />);
const div = container.querySelector("div")!;
const svg = container.querySelector("svg")!;
const before = svg.getAttribute("viewBox");
fireEvent.mouseDown(div, { button: 1, clientX: 200, clientY: 200 });
fireEvent.mouseMove(window, { clientX: 210, clientY: 205 });
fireEvent.mouseUp(window);
expect(container.querySelector("svg")).toBeTruthy();
expect(svg.getAttribute("viewBox")).not.toBe(before);
restore();
});

it("right-click on container starts pan", () => {
const restore = setupRO();
const { container } = render(<PlotCanvas />);
const div = container.querySelector("div")!;
const svg = container.querySelector("svg")!;
const before = svg.getAttribute("viewBox");
fireEvent.mouseDown(div, { button: 2, clientX: 220, clientY: 220 });
fireEvent.mouseMove(window, { clientX: 245, clientY: 235 });
fireEvent.mouseUp(window);
expect(svg.getAttribute("viewBox")).not.toBe(before);
restore();
});

it("right-click on import pans canvas without moving the import", () => {
const restore = setupRO();
const path = createSvgPath({ d: "M0,0 L100,100" });
const imp = createSvgImport({ name: "pan-target", paths: [path] });
useCanvasStore.setState({ imports: [imp] });
const { container } = render(<PlotCanvas />);
const svg = container.querySelector("svg")!;
const beforeViewBox = svg.getAttribute("viewBox");
const beforeImport = useCanvasStore.getState().imports[0];
const hitRect = container.querySelector("rect[fill='transparent']")!;

fireEvent.mouseDown(hitRect, {
button: 2,
clientX: 180,
clientY: 180,
});
fireEvent.mouseMove(window, { clientX: 210, clientY: 195 });
fireEvent.mouseUp(window);

const afterImport = useCanvasStore.getState().imports[0];
expect(svg.getAttribute("viewBox")).not.toBe(beforeViewBox);
expect(afterImport.x).toBe(beforeImport.x);
expect(afterImport.y).toBe(beforeImport.y);
});

// ── SVG onClick deselects ──────────────────────────────────────────
Expand Down Expand Up @@ -625,27 +688,6 @@ describe("PlotCanvas", () => {

// ── HandleOverlay interactions (require ResizeObserver with size) ──

const setupRO = () => {
const Original = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
_cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this._cb = cb;
}
observe(el: Element) {
this._cb(
[{ contentRect: { width: 800, height: 600 } } as ResizeObserverEntry],
this as unknown as ResizeObserver,
);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
return () => {
globalThis.ResizeObserver = Original;
};
};

it("handle-delete button removes the selected import", () => {
const restore = setupRO();
const path = createSvgPath({ d: "M0,0 L100,100" });
Expand Down
Loading