Skip to content

Commit

Permalink
Added rulers
Browse files Browse the repository at this point in the history
  • Loading branch information
mateuszmigas committed Mar 7, 2024
1 parent 91dbb5a commit 404da39
Show file tree
Hide file tree
Showing 10 changed files with 271 additions and 11 deletions.
174 changes: 174 additions & 0 deletions apps/web/src/components/Ruler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useListener, useStableCallback } from "@/hooks";
import { useResizeObserver } from "@/hooks/useResizeObserver";
import { useSettingsStore } from "@/store";
import { Viewport } from "@/utils/manipulation";
import { Observable } from "@/utils/observable";
import { useEffect, useRef } from "react";

const rulerConfig = {
dpi: window.devicePixelRatio,
offset: 30,
shortLine: 15,
longLine: 30,
lineThickness: 1,
cellSizeDefault: 50,
subCellsCount: 5,
font: "16px Arial",
};

const calculateCellSize = (zoom: number) => {
const { cellSizeDefault, subCellsCount: subCells } = rulerConfig;
let cellSize = cellSizeDefault * zoom;

while (cellSize > cellSizeDefault * subCells) {
cellSize = cellSize / subCells;
}

while (cellSize < cellSizeDefault) {
cellSize = cellSize * subCells;
}

return cellSize;
};

const drawRuler = (
context: CanvasRenderingContext2D,
viewport: Viewport,
color: string,
orientation: "horizontal" | "vertical"
) => {
const {
dpi,
offset,
shortLine,
longLine,
lineThickness,
subCellsCount,
font,
} = rulerConfig;

context.clearRect(0, 0, context.canvas.width, context.canvas.height);
context.fillStyle = color;
context.lineWidth = 1;
context.font = font;

const cellSize = calculateCellSize(viewport.zoom) * dpi;

const total =
(orientation === "horizontal"
? context.canvas.width
: context.canvas.height) * dpi;

const position =
(orientation === "horizontal" ? viewport.position.x : viewport.position.y) *
dpi;

let start = (position % cellSize) - cellSize;

while (start < total) {
const pos = ~~(start - offset * dpi);

if (orientation === "horizontal") {
context.fillRect(pos, 0, lineThickness, longLine);

for (let i = 0; i < subCellsCount; i++) {
const subCellPos = ~~(pos + i * (cellSize / subCellsCount));
context.fillRect(subCellPos, 0, lineThickness, shortLine);
}

const canvasPos = ~~Math.round((start - position) / viewport.zoom / dpi);
context.fillText(canvasPos.toString(), pos + 4, longLine);
} else {
context.fillRect(0, pos, longLine, lineThickness);

for (let i = 0; i < subCellsCount; i++) {
const subCellPos = ~~(pos + i * (cellSize / subCellsCount));
context.fillRect(0, subCellPos, shortLine, lineThickness);
}

const canvasPos = ~~Math.round((start - position) / viewport.zoom / dpi);
context.save();
context.textAlign = "right";
context.translate(longLine, pos + 4);
context.rotate(-Math.PI / 2);
context.fillText(canvasPos.toString(), 0, 0);
context.restore();
}

start += cellSize;
}
};

export const Ruler = (props: { viewport: Observable<Viewport> }) => {
const { viewport } = props;
const theme = useSettingsStore((state) => state.theme);
const canvasHorizontalRef = useRef<HTMLCanvasElement>(null);
const canvasVerticalRef = useRef<HTMLCanvasElement>(null);
const canvasHorizontalContextRef = useRef<CanvasRenderingContext2D | null>(
null
);
const canvasVerticalContextRef = useRef<CanvasRenderingContext2D | null>(
null
);

const drawRulers = useStableCallback((viewport: Viewport) => {
const color = theme === "dark" ? "white" : "black";
canvasHorizontalContextRef.current &&
drawRuler(
canvasHorizontalContextRef.current!,
viewport,
color,
"horizontal"
);
canvasVerticalRef.current &&
drawRuler(canvasVerticalContextRef.current!, viewport, color, "vertical");
});

useEffect(() => {
if (canvasHorizontalRef.current) {
const rectangle = canvasHorizontalRef.current.getBoundingClientRect();
canvasHorizontalRef.current.width = rectangle.width;
canvasHorizontalContextRef.current =
canvasHorizontalRef.current.getContext("2d");
}
if (canvasVerticalRef.current) {
const rectangle = canvasVerticalRef.current.getBoundingClientRect();
canvasVerticalRef.current.height = rectangle.height;
canvasVerticalContextRef.current =
canvasVerticalRef.current.getContext("2d");
}
}, []);

useListener(viewport, drawRulers);

useResizeObserver(canvasHorizontalRef, (rectangle) => {
if (canvasHorizontalRef.current) {
canvasHorizontalRef.current.width = rectangle.width * rulerConfig.dpi;
}
});

useResizeObserver(canvasVerticalRef, (rectangle) => {
if (canvasVerticalRef.current) {
canvasVerticalRef.current.height = rectangle.height * rulerConfig.dpi;
}
});

return (
<div className="absolute size-full pointer-events-none">
<canvas
ref={canvasHorizontalRef}
height={rulerConfig.offset * rulerConfig.dpi}
className="pixelated-canvas absolute left-[30px] h-[30px] w-full"
></canvas>
<canvas
ref={canvasVerticalRef}
width={rulerConfig.offset * rulerConfig.dpi}
className="pixelated-canvas absolute top-[30px] w-[30px] h-full"
></canvas>
<div className=" size-[30px] border-r border-b flex justify-center items-center">
PX
</div>
</div>
);
};

29 changes: 20 additions & 9 deletions apps/web/src/components/canvasViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import {
useViewportManipulator,
useFitToViewport,
useDrawTool,
useObservable,
} from "@/hooks";
import { useToolStore } from "@/store/toolState";
import { Ruler } from "./Ruler";
import { useResizeObserver } from "@/hooks/useResizeObserver";

//temp
const size = {
Expand All @@ -21,7 +24,7 @@ const layerId = "1";

export const CanvasViewport = () => {
const hostElementRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<Viewport>(defaultViewport);
const viewport = useObservable<Viewport>(defaultViewport);
const renderer = useCanvasRenderer(hostElementRef, size);
const drawToolId = useToolStore((state) => state.selectedToolId);
const drawToolSettings = useToolStore(
Expand All @@ -32,23 +35,31 @@ export const CanvasViewport = () => {
hostElementRef,
drawToolId,
drawToolSettings,
(position) => screenToViewportPosition(position, viewportRef.current!),
(position) => screenToViewportPosition(position, viewport.getValue()),
() => renderer.getDrawContext(layerId)
);

const updateViewport = (viewport: Viewport) => {
viewportRef.current = viewport;
renderer.setViewport(viewport);
const updateViewport = (partialViewport: Partial<Viewport>) => {
const newViewport = {
...viewport.getValue(),
...partialViewport,
};
viewport.setValue(newViewport);
renderer.setViewport(newViewport);
};

useViewportManipulator(
hostElementRef,
() => viewportRef.current,
() => viewport.getValue(),
updateViewport
);

useFitToViewport(hostElementRef, size, (viewport) => {
updateViewport(viewport);
useResizeObserver(hostElementRef, (newSize) =>
updateViewport({ size: newSize })
);

useFitToViewport(hostElementRef, size, (newViewport) => {
updateViewport(newViewport);
hostElementRef.current!.style.opacity = "1";
});

Expand All @@ -59,7 +70,7 @@ export const CanvasViewport = () => {
ref={hostElementRef}
className="absolute size-full overflow-hidden transition-opacity duration-1000 cursor-crosshair"
></div>
<div className="absolute p-small">Middle mouse to move/zoom</div>
<Ruler viewport={viewport}></Ruler>
</div>
);
};
3 changes: 3 additions & 0 deletions apps/web/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ export { useAfterPaintEffect } from "./useAfterPaintEffect";
export { useViewportManipulator } from "./useViewportManipulator";
export { useFitToViewport } from "./useFitToViewport";
export { useSyncTheme } from "./useSyncTheme";
export { useStableCallback } from "./useStableCallback";
export { useObservable } from "./useObservable";
export { useListener } from "./useListener";

2 changes: 1 addition & 1 deletion apps/web/src/hooks/useCanvasRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const useCanvasRenderer = (
canvas.style.width = `${size.width}px`;
canvas.style.height = `${size.height}px`;
canvas.className =
"pixelated-canvas pointer-events-none origin-top-left shadow-2xl border";
"pixelated-canvas pointer-events-none origin-top-left outline outline-border shadow-2xl box-content";
hostElement.appendChild(canvas);
canvasRef.current = canvas;
const offscreenCanvas = canvasRef.current?.transferControlToOffscreen();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/hooks/useFitToViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const useFitToViewport = (
const rect = hostElement.getBoundingClientRect();
const margin = Math.min(rect.width, rect.height) * 0.1;
const viewport = calculateFitViewport(
rect,
{ width: rect.width, height: rect.height },
{ x: 0, y: 0, ...size },
margin
);
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/hooks/useListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Observable } from "@/utils/observable";
import { useStableCallback } from ".";
import { useEffect } from "react";

export const useListener = <T>(
observable: Observable<T>,
onChange: (value: T) => void
) => {
const stableOnChange = useStableCallback(onChange);

useEffect(() => {
const unsubscribe = observable.subscribe(stableOnChange);
return () => unsubscribe();
}, []);
};

6 changes: 6 additions & 0 deletions apps/web/src/hooks/useObservable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Observable } from "@/utils/observable";
import { useMemo } from "react";

export const useObservable = <T,>(value: T) =>
useMemo(() => new Observable<T>(value), []);

15 changes: 15 additions & 0 deletions apps/web/src/hooks/useStableCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useCallback, useRef } from "react";

//does not work with concurrent mode
export const useStableCallback = <T extends (...args: never[]) => unknown>(
callback: T
): T => {
const callbackRef = useRef<T | null>(null);
callbackRef.current = callback;

return useCallback(
(...args: unknown[]) => callbackRef.current?.(...(args as never[])),
[]
) as unknown as T;
};

4 changes: 4 additions & 0 deletions apps/web/src/utils/manipulation/viewport.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Position, Rectangle, Size, scaleRectangle } from "../common";

export type Viewport = {
size: Size;
position: Position;
zoom: number;
};

export const defaultViewport: Viewport = {
size: { width: 0, height: 0 },
position: { x: 0, y: 0 },
zoom: 1,
};
Expand All @@ -15,6 +17,7 @@ export const zoomAtPosition = (
zoom: number,
position: Position
): Viewport => ({
...currentViewport,
position: {
x: position.x - (position.x - currentViewport.position.x) * zoom,
y: position.y - (position.y - currentViewport.position.y) * zoom,
Expand Down Expand Up @@ -55,6 +58,7 @@ export const calculateFitViewport = (
};

return {
size: windowSize,
position: { x: newPosition.x + padding, y: newPosition.y + padding },
zoom: newZoom,
};
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/utils/observable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
type Subscriber<T> = (data: T) => void;

export class Observable<T> {
private listeners: Subscriber<T>[] = [];

constructor(private data: T) {}

subscribe(listener: Subscriber<T>) {
this.listeners.push(listener);

return () => {
this.listeners = this.listeners.filter(
(subscriber) => subscriber !== listener
);
};
}

getValue() {
return this.data;
}

setValue(data: T) {
this.data = data;
this.notify();
}

notify() {
this.listeners.forEach((observer) => observer(this.data));
}
}

0 comments on commit 404da39

Please sign in to comment.