Skip to content

Commit

Permalink
[feat] Infinite Canvas Zoom controls (#792)
Browse files Browse the repository at this point in the history
  • Loading branch information
RaghavGanesh7 authored Nov 18, 2024
1 parent f8b7bfd commit 9e53876
Show file tree
Hide file tree
Showing 7 changed files with 432 additions and 218 deletions.
14 changes: 14 additions & 0 deletions apps/studio/common/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export class Hotkey {
static readonly INSERT_DIV = new Hotkey('r', 'Insert Div');
static readonly RELOAD_APP = new Hotkey('mod+r', 'Reload App');

// Zoom
static readonly ZOOM_FIT = new Hotkey('mod+0', 'Zoom Fit');
static readonly ZOOM_IN = new Hotkey('mod+equal', 'Zoom In');
static readonly ZOOM_OUT = new Hotkey('mod+minus', 'Zoom Out');

// Actions
static readonly UNDO = new Hotkey('mod+z', 'Undo');
static readonly REDO = new Hotkey('mod+shift+z', 'Redo');
Expand Down Expand Up @@ -56,6 +61,15 @@ export class Hotkey {
if (value === 'ctrl') {
return isMac ? '⌃' : 'Ctrl';
}
if (value === 'equal') {
return '=';
}
if (value === 'minus') {
return '-';
}
if (value === 'plus') {
return '+';
}
return capitalizeFirstLetter(value);
})
.join(' ');
Expand Down
5 changes: 3 additions & 2 deletions apps/studio/src/components/ui/hotkeys-label.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Kbd } from '@onlook/ui/kbd';
import { cn } from '@onlook/ui/utils';
import type { Hotkey } from '/common/hotkeys';

export function HotKeyLabel({ hotkey }: { hotkey: Hotkey }) {
export function HotKeyLabel({ hotkey, className }: { hotkey: Hotkey; className?: string }) {
return (
<span className="flex items-center space-x-2">
<span className={cn('flex items-center space-x-2', className)}>
<span>{hotkey.description}</span>

<Kbd>
Expand Down
14 changes: 11 additions & 3 deletions apps/studio/src/routes/editor/Canvas/Hotkeys/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@ import { Hotkey } from '/common/hotkeys';
interface HotkeysAreaProps {
children: ReactNode;
scale: number;
setScale: React.Dispatch<React.SetStateAction<number>>;
setScale: (scale: number) => void;
setPosition: (position: { x: number; y: number }) => void;
}

const HotkeysArea = ({ children, scale, setScale }: HotkeysAreaProps) => {
const HotkeysArea = ({ children, scale, setScale, setPosition }: HotkeysAreaProps) => {
const editorEngine = useEditorEngine();

// Zoom
useHotkeys('mod+0', () => setScale(DefaultSettings.SCALE), { preventDefault: true });
useHotkeys(
'mod+0',
() => {
setScale(DefaultSettings.SCALE);
setPosition({ x: DefaultSettings.POSITION.x, y: DefaultSettings.POSITION.y });
},
{ preventDefault: true },
);
useHotkeys('mod+equal', () => setScale(scale * 1.2), { preventDefault: true });
useHotkeys('mod+minus', () => setScale(scale * 0.8), { preventDefault: true });

Expand Down
298 changes: 153 additions & 145 deletions apps/studio/src/routes/editor/Canvas/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,163 +5,171 @@ import { type ReactNode, useEffect, useRef, useState } from 'react';
import HotkeysArea from './Hotkeys';
import PanOverlay from './PanOverlay';

const Canvas = observer(({ children }: { children: ReactNode }) => {
const ZOOM_SENSITIVITY = 0.006;
const PAN_SENSITIVITY = 0.52;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 3;
const MAX_X = 10000;
const MAX_Y = 10000;
const MIN_X = -5000;
const MIN_Y = -5000;

const editorEngine = useEditorEngine();
const containerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);
const [scale, setScale] = useState(editorEngine.canvas.scale);
const [position, setPosition] = useState(editorEngine.canvas.position);

useEffect(() => {
editorEngine.canvas.scale = scale;
editorEngine.canvas.position = position;
}, [position, scale]);

const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
handleZoom(event);
} else {
handlePan(event);
}
};

const handleZoom = (event: WheelEvent) => {
if (!containerRef.current) {
return;
}
event.preventDefault();
const zoomFactor = -event.deltaY * ZOOM_SENSITIVITY;
const rect = containerRef.current.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const Canvas = observer(
({
children,
scale,
position,
onPositionChange,
onScaleChange,
}: {
children: ReactNode;
scale: number;
position: { x: number; y: number };
onPositionChange: (position: any) => void;
onScaleChange: (scale: number) => void;
}) => {
const ZOOM_SENSITIVITY = 0.006;
const PAN_SENSITIVITY = 0.52;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 3;
const MAX_X = 10000;
const MAX_Y = 10000;
const MIN_X = -5000;
const MIN_Y = -5000;

const editorEngine = useEditorEngine();
const containerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);

const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
handleZoom(event);
} else {
handlePan(event);
}
};

const newScale = scale * (1 + zoomFactor);
const lintedScale = clampZoom(newScale);
const handleZoom = (event: WheelEvent) => {
if (!containerRef.current) {
return;
}
event.preventDefault();
const zoomFactor = -event.deltaY * ZOOM_SENSITIVITY;
const rect = containerRef.current.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;

const newScale = scale * (1 + zoomFactor);
const lintedScale = clampZoom(newScale);

const deltaX = (x - position.x) * zoomFactor;
const deltaY = (y - position.y) * zoomFactor;

onScaleChange(lintedScale);

if (newScale < MIN_ZOOM || newScale > MAX_ZOOM) {
return;
}
onPositionChange((prevPosition: { x: number; y: number }) =>
clampPosition(
{
x: prevPosition.x - deltaX,
y: prevPosition.y - deltaY,
},
scale,
),
);
};

const deltaX = (x - position.x) * zoomFactor;
const deltaY = (y - position.y) * zoomFactor;
function clampZoom(scale: number) {
return Math.min(Math.max(scale, MIN_ZOOM), MAX_ZOOM);
}

setScale(lintedScale);
function clampPosition(position: { x: number; y: number }, scale: number) {
const effectiveMaxX = MAX_X * scale;
const effectiveMaxY = MAX_Y * scale;
const effectiveMinX = MIN_X * scale;
const effectiveMinY = MIN_Y * scale;

if (newScale < MIN_ZOOM || newScale > MAX_ZOOM) {
return;
return {
x: Math.min(Math.max(position.x, effectiveMinX), effectiveMaxX),
y: Math.min(Math.max(position.y, effectiveMinY), effectiveMaxY),
};
}
setPosition((prevPosition) =>
clampPosition(
{
x: prevPosition.x - deltaX,
y: prevPosition.y - deltaY,
},
scale,
),
);
};

function clampZoom(scale: number) {
return Math.min(Math.max(scale, MIN_ZOOM), MAX_ZOOM);
}
const handlePan = (event: WheelEvent) => {
const deltaX = (event.deltaX + (event.shiftKey ? event.deltaY : 0)) * PAN_SENSITIVITY;
const deltaY = (event.shiftKey ? 0 : event.deltaY) * PAN_SENSITIVITY;
onPositionChange((prevPosition: { x: number; y: number }) =>
clampPosition(
{
x: prevPosition.x - deltaX,
y: prevPosition.y - deltaY,
},
scale,
),
);
};

function clampPosition(position: { x: number; y: number }, scale: number) {
const effectiveMaxX = MAX_X * scale;
const effectiveMaxY = MAX_Y * scale;
const effectiveMinX = MIN_X * scale;
const effectiveMinY = MIN_Y * scale;
const handleCanvasClicked = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target !== containerRef.current) {
return;
}
editorEngine.webviews.deselectAll();
editorEngine.webviews.notify();
editorEngine.clear();
};

return {
x: Math.min(Math.max(position.x, effectiveMinX), effectiveMaxX),
y: Math.min(Math.max(position.y, effectiveMinY), effectiveMaxY),
useEffect(() => {
const div = containerRef.current;
if (div) {
div.addEventListener('wheel', handleWheel, { passive: false });
div.addEventListener('mousedown', middleMouseButtonDown);
div.addEventListener('mouseup', middleMouseButtonUp);
return () => {
div.removeEventListener('wheel', handleWheel);
div.removeEventListener('mousedown', middleMouseButtonDown);
div.removeEventListener('mouseup', middleMouseButtonUp);
};
}
}, [handleWheel]);

const middleMouseButtonDown = (e: MouseEvent) => {
if (e.button === 1) {
editorEngine.mode = EditorMode.PAN;
setIsPanning(true);
e.preventDefault();
e.stopPropagation();
}
};
}

const handlePan = (event: WheelEvent) => {
const deltaX = (event.deltaX + (event.shiftKey ? event.deltaY : 0)) * PAN_SENSITIVITY;
const deltaY = (event.shiftKey ? 0 : event.deltaY) * PAN_SENSITIVITY;
setPosition((prevPosition) =>
clampPosition(
{
x: prevPosition.x - deltaX,
y: prevPosition.y - deltaY,
},
scale,
),
);
};

const handleCanvasClicked = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target !== containerRef.current) {
return;
}
editorEngine.webviews.deselectAll();
editorEngine.webviews.notify();
editorEngine.clear();
};

useEffect(() => {
const div = containerRef.current;
if (div) {
div.addEventListener('wheel', handleWheel, { passive: false });
div.addEventListener('mousedown', middleMouseButtonDown);
div.addEventListener('mouseup', middleMouseButtonUp);
return () => {
div.removeEventListener('wheel', handleWheel);
div.removeEventListener('mousedown', middleMouseButtonDown);
div.removeEventListener('mouseup', middleMouseButtonUp);
};
}
}, [handleWheel]);

const middleMouseButtonDown = (e: MouseEvent) => {
if (e.button === 1) {
editorEngine.mode = EditorMode.PAN;
setIsPanning(true);
e.preventDefault();
e.stopPropagation();
}
};

const middleMouseButtonUp = (e: MouseEvent) => {
if (e.button === 1) {
editorEngine.mode = EditorMode.DESIGN;
setIsPanning(false);
e.preventDefault();
e.stopPropagation();
}
};

return (
<HotkeysArea scale={scale} setScale={setScale}>
<div
ref={containerRef}
className="overflow-hidden bg-background-onlook flex flex-grow relative"
onClick={handleCanvasClicked}
>
const middleMouseButtonUp = (e: MouseEvent) => {
if (e.button === 1) {
editorEngine.mode = EditorMode.DESIGN;
setIsPanning(false);
e.preventDefault();
e.stopPropagation();
}
};

return (
<HotkeysArea scale={scale} setScale={onScaleChange} setPosition={onPositionChange}>
<div
style={{
transition: 'transform ease',
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transformOrigin: '0 0',
}}
ref={containerRef}
className="overflow-hidden bg-background-onlook flex flex-grow relative"
onClick={handleCanvasClicked}
>
{children}
<div
id="canvas-container"
style={{
transition: 'transform ease',
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transformOrigin: '0 0',
}}
>
{children}
</div>
<PanOverlay
setPosition={onPositionChange}
clampPosition={(position) => clampPosition(position, scale)}
isPanning={isPanning}
setIsPanning={setIsPanning}
/>
</div>
<PanOverlay
setPosition={setPosition}
clampPosition={(position) => clampPosition(position, scale)}
isPanning={isPanning}
setIsPanning={setIsPanning}
/>
</div>
</HotkeysArea>
);
});
</HotkeysArea>
);
},
);

export default Canvas;
Loading

0 comments on commit 9e53876

Please sign in to comment.