Skip to content

Commit

Permalink
Merge pull request #65 from low-earth-orbit/pan-stage
Browse files Browse the repository at this point in the history
Add zoom control
  • Loading branch information
low-earth-orbit authored Oct 3, 2024
2 parents 4982792 + ebced72 commit f3481ce
Show file tree
Hide file tree
Showing 7 changed files with 332 additions and 13 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Konva Whiteboard

This is an work-in-progress hobby project aimed at creating a sketch board app, similar to [Microsoft Whiteboard](https://www.microsoft.com/en-ca/microsoft-365/microsoft-whiteboard/digital-whiteboard-app), or a graphic design tool like [Polotno](https://studio.polotno.com/).
This is an work-in-progress hobby project aimed at creating a whiteboard app using Konva library.

Demo: [Try it here](https://whiteboard.leohong.dev)

Expand All @@ -17,7 +17,7 @@ Although there isn’t a strict timeline, the project is divided into three phas

- Phase 1: Front-end only, focusing on basic drawing and canvas functionality.
- Phase 2: Introduce a backend and expand on front-end features.
- Phase 3: Advanced features such as sharing, live edit, and user management.
- Phase 3: Collaboration such as sharing, live edit, and user management.

## Implemented Features

Expand Down Expand Up @@ -50,6 +50,7 @@ Although there isn’t a strict timeline, the project is divided into three phas
- Select object
- Delete an object or clear the entire canvas
- History (undo & redo)
- Zoom control
- Keyboard shortcuts for delete/undo/redo actions
- Persistent canvas data stored in browser's local storage

Expand Down
67 changes: 60 additions & 7 deletions components/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { v4 as uuid } from "uuid";
import { Stage } from "react-konva";
Expand Down Expand Up @@ -31,6 +31,9 @@ import TextPanel from "./toolbar/TextPanel";
import { setStrokeColor, setStrokeWidth } from "@/redux/shapeSlice";
import { Fab } from "@mui/material";
import GitHubIcon from "./icons/GitHubIcon";
import ZoomToolbar from "./toolbar/ZoomToolbar";
import Konva from "konva";
import { KonvaEventObject } from "konva/lib/Node";

export interface StageSizeType {
width: number;
Expand Down Expand Up @@ -97,6 +100,9 @@ export default function Canvas() {
const [isShapePanelVisible, setShapePanelVisible] = useState(false);
const [isTextPanelVisible, setTextPanelVisible] = useState(false);

const [zoomLevel, setZoomLevel] = useState(1); // Default zoom level = 100%
const stageRef = useRef<Konva.Stage | null>(null);

// Dark mode listener
useEffect(() => {
if (typeof window !== "undefined") {
Expand Down Expand Up @@ -342,7 +348,7 @@ export default function Canvas() {

// If the current selected tool is addText or add shapes
if (selectedTool.includes("add")) {
const pos = e.target.getStage().getPointerPosition();
const pos = e.target.getStage().getRelativePointerPosition();
const { x, y } = pos;

// Add new object based on tool
Expand Down Expand Up @@ -371,7 +377,7 @@ export default function Canvas() {

// If the current selected tool is eraser or pen
if (selectedTool === "eraser" || selectedTool === "pen") {
const pos = e.target.getStage().getPointerPosition();
const pos = e.target.getStage().getRelativePointerPosition();
const newLine: CanvasObjectType = {
id: uuid(),
tool: selectedTool, // eraser or pen
Expand Down Expand Up @@ -412,7 +418,7 @@ export default function Canvas() {
// Creating new text/object is in progress
if (isInProgress && newObject && newObject.type !== "ink") {
const stage = e.target.getStage();
const point = stage.getPointerPosition();
const point = stage.getRelativePointerPosition();

const width = Math.max(Math.abs(point.x - newObject.x!) || 5);
const height = Math.max(Math.abs(point.y - newObject.y!) || 5);
Expand All @@ -436,7 +442,7 @@ export default function Canvas() {
// Freehand drawing (eraser or pen) in progress
if (isInProgress && newObject) {
const stage = e.target.getStage();
const point = stage.getPointerPosition();
const point = stage.getRelativePointerPosition();

const updatedObject = {
...newObject,
Expand All @@ -460,15 +466,55 @@ export default function Canvas() {
}
};

function handleWheelZoom(e: KonvaEventObject<WheelEvent>): void {
// stop default scrolling
e.evt.preventDefault();

const stage = stageRef.current;

if (stage) {
const oldScale = stage.scaleX();
const pointer = stage.getPointerPosition();

if (pointer) {
const mousePointTo = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale,
};

let direction = e.evt.deltaY < 0 ? 1 : -1;

const scaleBy = 1.05; // scale factor per wheel movement
const newScale =
direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;

const scale = Math.max(0.1, Math.min(newScale, 3)); // limit in range

stage.scale({ x: scale, y: scale });

var newPos = {
x: pointer.x - mousePointTo.x * scale,
y: pointer.y - mousePointTo.y * scale,
};
stage.position(newPos);

setZoomLevel(scale);
}
}
}

return (
<>
<Stage
ref={stageRef}
width={window.innerWidth}
height={window.innerHeight}
onMouseDown={handleMouseDown}
onMousemove={handleMouseMove}
onMouseup={handleMouseUp}
onTouchStart={handleMouseDown}
draggable={selectedTool == "select"}
onWheel={(e) => handleWheelZoom(e)}
>
<InkLayer objects={canvasObjects} newObject={newObject} />
<ShapeLayer
Expand All @@ -490,6 +536,7 @@ export default function Canvas() {
}
onChange={updateSelectedObject}
setSidePanelVisible={setTextPanelVisible}
zoomLevel={zoomLevel}
/>
</Stage>
<Toolbar
Expand Down Expand Up @@ -525,13 +572,14 @@ export default function Canvas() {
selectedObjectId={selectedObjectId}
/>
<Fab
id="github-fab"
size="small"
variant="extended"
aria-label="Link to GitHub repository of this project"
style={{
position: "fixed",
top: "16px",
left: "16px",
top: "8px",
left: "8px",
}}
onClick={() =>
window.open(
Expand All @@ -543,6 +591,11 @@ export default function Canvas() {
<GitHubIcon sx={{ mr: 1 }} />
View Source
</Fab>
<ZoomToolbar
zoomLevel={zoomLevel}
setZoomLevel={setZoomLevel}
stageRef={stageRef}
/>
</>
);
}
4 changes: 2 additions & 2 deletions components/icons/GitHubIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export default function GitHubIcon({ ...props }) {
<SvgIcon viewBox="0 0 98 96" width="24" height="24" sx={props.sx}>
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill="#24292f"
/>
Expand Down
3 changes: 3 additions & 0 deletions components/text/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ type Props = {
isSelected: boolean;
onSelect: (e: any) => void;
onChange: (newAttrs: Partial<CanvasObjectType>) => void;
zoomLevel: number;
};

export default function TextField({
objectProps,
isSelected,
onSelect,
onChange,
zoomLevel,
}: Props) {
const textRef = useRef<Konva.Text>(null);
const trRef = useRef<Konva.Transformer>(null);
Expand Down Expand Up @@ -124,6 +126,7 @@ export default function TextField({
textarea.style.textAlign = node.align();
textarea.style.color = node.fill() as string;
textarea.style.transformOrigin = "left top";
textarea.style.scale = zoomLevel.toString();

// set rotation
const rotation = node.rotation();
Expand Down
4 changes: 3 additions & 1 deletion components/text/TextLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { CanvasObjectType } from "../Canvas";
import TextField from "./TextField";
import { RootState } from "@/redux/store";
import { useDispatch, useSelector } from "react-redux";
import { updateCanvasObject } from "@/redux/canvasSlice";
import {
setLineSpacing,
setTextAlignment,
Expand All @@ -23,6 +22,7 @@ type Props = {
selectedObjectId: string,
) => void;
setSidePanelVisible: (isVisible: boolean) => void;
zoomLevel: number;
};

export default function TextLayer({
Expand All @@ -32,6 +32,7 @@ export default function TextLayer({
setSelectedObjectId,
onChange,
setSidePanelVisible,
zoomLevel,
}: Props) {
const dispatch = useDispatch();

Expand Down Expand Up @@ -86,6 +87,7 @@ export default function TextLayer({
onChange={(newAttrs: Partial<CanvasObjectType>) =>
onChange(newAttrs, text.id)
}
zoomLevel={zoomLevel}
/>
))}
</Layer>
Expand Down
2 changes: 1 addition & 1 deletion components/toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ function Toolbar({

return (
<div
className={`absolute bottom-5 left-1/2 transform -translate-x-1/2 ${toolbarBgColor}`}
className={`absolute bottom-2 left-1/2 transform -translate-x-1/2 ${toolbarBgColor}`}
>
<ButtonGroup
variant="contained"
Expand Down
Loading

0 comments on commit f3481ce

Please sign in to comment.