From 5256096d76be4685d3a6f06e2be4926d0b0a9def Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 19 Feb 2020 08:25:01 -0800 Subject: [PATCH] Fast & Furious (#655) * [WIP] Fast & Furious * ensure we translate before scaling * implement canvas caching for rest of elements * remove unnecessary ts-ignore * fix for devicePixelRatio * initialize missing element props on restore * factor out canvas padding * remove unnecessary filtering * simplify renderElement * regenerate canvas on prop changes * revert swapping shape resetting with canvas * fix blurry rendering * apply devicePixelRatio when clearing canvas * improve blurriness; fix arrow canvas offset * revert canvas clearing changes in anticipation of merge * normalize scrollX/Y on update * fix getDerivedStateFromProps * swap derivedState for type brands * tweak types * remove renderScene offsets * move selection element translations to renderElement * dry out canvas zoom transformations * fix padding offset * Render cached canvas based on the zoom level Co-authored-by: David Luzar Co-authored-by: Preet <833927+pshihn@users.noreply.github.com> --- src/appState.ts | 6 +- src/clipboard.ts | 2 +- src/element/newElement.ts | 5 + src/history.ts | 1 + src/index.tsx | 40 ++++--- src/renderer/renderElement.ts | 189 ++++++++++++++++++++++++++++------ src/renderer/renderScene.ts | 79 +++++++------- src/scene/data.ts | 20 +++- src/scene/export.ts | 8 +- src/scene/scrollbars.ts | 9 +- src/scene/types.ts | 9 +- src/styles.scss | 9 ++ src/types.ts | 6 +- 13 files changed, 269 insertions(+), 114 deletions(-) diff --git a/src/appState.ts b/src/appState.ts index 8a12f684..84a9e990 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -1,4 +1,4 @@ -import { AppState } from "./types"; +import { AppState, FlooredNumber } from "./types"; import { getDateTime } from "./utils"; const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; @@ -20,8 +20,8 @@ export function getDefaultAppState(): AppState { currentItemOpacity: 100, currentItemFont: "20px Virgil", viewBackgroundColor: "#ffffff", - scrollX: 0, - scrollY: 0, + scrollX: 0 as FlooredNumber, + scrollY: 0 as FlooredNumber, cursorX: 0, cursorY: 0, scrolledOutside: false, diff --git a/src/clipboard.ts b/src/clipboard.ts index e5047c7b..f422fc40 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -20,7 +20,7 @@ export async function copyToAppClipboard( elements: readonly ExcalidrawElement[], ) { CLIPBOARD = JSON.stringify( - getSelectedElements(elements).map(({ shape, ...el }) => el), + getSelectedElements(elements).map(({ shape, canvas, ...el }) => el), ); try { // when copying to in-app clipboard, clear system clipboard so that if diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 13e37f5d..3cd31066 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -36,6 +36,10 @@ export function newElement( seed: randomSeed(), shape: null as Drawable | Drawable[] | null, points: [] as Point[], + canvas: null as HTMLCanvasElement | null, + canvasZoom: 1, // The zoom level used to render the cached canvas + canvasOffsetX: 0, + canvasOffsetY: 0, }; return element; } @@ -48,6 +52,7 @@ export function newTextElement( const metrics = measureText(text, font); const textElement: ExcalidrawTextElement = { ...element, + shape: null, type: "text", text: text, font: font, diff --git a/src/history.ts b/src/history.ts index 43e7a6ff..8aa2e870 100644 --- a/src/history.ts +++ b/src/history.ts @@ -16,6 +16,7 @@ class SceneHistory { elements: elements.map(({ shape, ...element }) => ({ ...element, shape: null, + canvas: null, points: appState.multiElement && appState.multiElement.id === element.id ? element.points.slice(0, -1) diff --git a/src/index.tsx b/src/index.tsx index 7cffae20..9f54a980 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -41,7 +41,7 @@ import { } from "./scene"; import { renderScene } from "./renderer"; -import { AppState } from "./types"; +import { AppState, FlooredNumber } from "./types"; import { ExcalidrawElement } from "./element/types"; import { @@ -106,6 +106,7 @@ import { t, languages, setLanguage, getLanguage } from "./i18n"; import { HintViewer } from "./components/HintViewer"; import { copyToAppClipboard, getClipboardContent } from "./clipboard"; +import { normalizeScroll } from "./scene/data"; let { elements } = createScene(); const { history } = createHistory(); @@ -143,8 +144,8 @@ export function viewportCoordsToSceneCoords( scrollY, zoom, }: { - scrollX: number; - scrollY: number; + scrollX: FlooredNumber; + scrollY: FlooredNumber; zoom: number; }, canvas: HTMLCanvasElement | null, @@ -166,8 +167,8 @@ export function sceneCoordsToViewportCoords( scrollY, zoom, }: { - scrollX: number; - scrollY: number; + scrollX: FlooredNumber; + scrollY: FlooredNumber; zoom: number; }, canvas: HTMLCanvasElement | null, @@ -651,6 +652,7 @@ export class App extends React.Component { public state: AppState = getDefaultAppState(); private onResize = () => { + elements = elements.map(el => ({ ...el, shape: null })); this.setState({}); }; @@ -958,8 +960,12 @@ export class App extends React.Component { lastY = e.clientY; this.setState({ - scrollX: this.state.scrollX - deltaX / this.state.zoom, - scrollY: this.state.scrollY - deltaY / this.state.zoom, + scrollX: normalizeScroll( + this.state.scrollX - deltaX / this.state.zoom, + ), + scrollY: normalizeScroll( + this.state.scrollY - deltaY / this.state.zoom, + ), }); }; const teardown = (lastMouseUp = () => { @@ -1294,7 +1300,9 @@ export class App extends React.Component { const x = e.clientX; const dx = x - lastX; this.setState({ - scrollX: this.state.scrollX - dx / this.state.zoom, + scrollX: normalizeScroll( + this.state.scrollX - dx / this.state.zoom, + ), }); lastX = x; return; @@ -1304,7 +1312,9 @@ export class App extends React.Component { const y = e.clientY; const dy = y - lastY; this.setState({ - scrollY: this.state.scrollY - dy / this.state.zoom, + scrollY: normalizeScroll( + this.state.scrollY - dy / this.state.zoom, + ), }); lastY = y; return; @@ -2004,8 +2014,8 @@ export class App extends React.Component { } this.setState(({ zoom, scrollX, scrollY }) => ({ - scrollX: scrollX - deltaX / zoom, - scrollY: scrollY - deltaY / zoom, + scrollX: normalizeScroll(scrollX - deltaX / zoom), + scrollY: normalizeScroll(scrollY - deltaY / zoom), })); }; @@ -2069,10 +2079,7 @@ export class App extends React.Component { } private saveDebounced = debounce(() => { - saveToLocalStorage( - elements.filter(x => x.type !== "selection"), - this.state, - ); + saveToLocalStorage(elements, this.state); }, 300); componentDidUpdate() { @@ -2087,6 +2094,9 @@ export class App extends React.Component { viewBackgroundColor: this.state.viewBackgroundColor, zoom: this.state.zoom, }, + { + renderOptimizations: true, + }, ); const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0; if (this.state.scrolledOutside !== scrolledOutside) { diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 331e845e..b4cc3444 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -1,18 +1,111 @@ -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { isTextElement } from "../element/typeChecks"; -import { getDiamondPoints, getArrowPoints } from "../element/bounds"; +import { + getDiamondPoints, + getArrowPoints, + getElementAbsoluteCoords, +} from "../element/bounds"; import { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; import { Point } from "roughjs/bin/geometry"; import { RoughSVG } from "roughjs/bin/svg"; import { RoughGenerator } from "roughjs/bin/generator"; -import { SVG_NS } from "../utils"; +import { SceneState } from "../scene/types"; +import { SVG_NS, distance } from "../utils"; +import rough from "roughjs/bin/rough"; + +const CANVAS_PADDING = 20; + +function generateElementCanvas(element: ExcalidrawElement, zoom: number) { + const canvas = document.createElement("canvas"); + var context = canvas.getContext("2d")!; + + const isLinear = /\b(arrow|line)\b/.test(element.type); + + if (isLinear) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + canvas.width = + distance(x1, x2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; + canvas.height = + distance(y1, y2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; + + element.canvasOffsetX = + element.x > x1 + ? Math.floor(distance(element.x, x1)) * window.devicePixelRatio + : 0; + element.canvasOffsetY = + element.y > y1 + ? Math.floor(distance(element.y, y1)) * window.devicePixelRatio + : 0; + context.translate( + element.canvasOffsetX * zoom, + element.canvasOffsetY * zoom, + ); + } else { + canvas.width = + element.width * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; + canvas.height = + element.height * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; + } + + context.translate(CANVAS_PADDING, CANVAS_PADDING); + context.scale(window.devicePixelRatio * zoom, window.devicePixelRatio * zoom); + + const rc = rough.canvas(canvas); + drawElementOnCanvas(element, rc, context); + element.canvas = canvas; + element.canvasZoom = zoom; + context.translate(-CANVAS_PADDING, -CANVAS_PADDING); +} + +function drawElementOnCanvas( + element: ExcalidrawElement, + rc: RoughCanvas, + context: CanvasRenderingContext2D, +) { + context.globalAlpha = element.opacity / 100; + switch (element.type) { + case "rectangle": + case "diamond": + case "ellipse": { + rc.draw(element.shape as Drawable); + break; + } + case "arrow": + case "line": { + (element.shape as Drawable[]).forEach(shape => rc.draw(shape)); + break; + } + default: { + if (isTextElement(element)) { + const font = context.font; + context.font = element.font; + const fillStyle = context.fillStyle; + context.fillStyle = element.strokeColor; + // Canvas does not support multiline text by default + const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); + const lineHeight = element.height / lines.length; + const offset = element.height - element.baseline; + for (let i = 0; i < lines.length; i++) { + context.fillText(lines[i], 0, (i + 1) * lineHeight - offset); + } + context.fillStyle = fillStyle; + context.font = font; + } else { + throw new Error(`Unimplemented type ${element.type}`); + } + } + } + context.globalAlpha = 1; +} function generateElement( element: ExcalidrawElement, generator: RoughGenerator, + sceneState?: SceneState, ) { if (!element.shape) { + element.canvas = null; switch (element.type) { case "rectangle": element.shape = generator.rectangle( @@ -32,6 +125,7 @@ function generateElement( seed: element.seed, }, ); + break; case "diamond": { const [ @@ -115,18 +209,64 @@ function generateElement( } break; } + case "text": { + // just to ensure we don't regenerate element.canvas on rerenders + element.shape = []; + break; + } } } + const zoom = sceneState ? sceneState.zoom : 1; + if (!element.canvas || element.canvasZoom !== zoom) { + generateElementCanvas(element, zoom); + } +} + +function drawElementFromCanvas( + element: ExcalidrawElement | ExcalidrawTextElement, + rc: RoughCanvas, + context: CanvasRenderingContext2D, + sceneState: SceneState, +) { + context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); + context.translate( + -CANVAS_PADDING / sceneState.zoom, + -CANVAS_PADDING / sceneState.zoom, + ); + context.drawImage( + element.canvas!, + Math.floor( + -element.canvasOffsetX + + (Math.floor(element.x) + sceneState.scrollX) * window.devicePixelRatio, + ), + Math.floor( + -element.canvasOffsetY + + (Math.floor(element.y) + sceneState.scrollY) * window.devicePixelRatio, + ), + element.canvas!.width / sceneState.zoom, + element.canvas!.height / sceneState.zoom, + ); + context.translate( + CANVAS_PADDING / sceneState.zoom, + CANVAS_PADDING / sceneState.zoom, + ); + context.scale(window.devicePixelRatio, window.devicePixelRatio); } export function renderElement( element: ExcalidrawElement, rc: RoughCanvas, context: CanvasRenderingContext2D, + renderOptimizations: boolean, + sceneState: SceneState, ) { const generator = rc.generator; switch (element.type) { case "selection": { + context.translate( + element.x + sceneState.scrollX, + element.y + sceneState.scrollY, + ); const fillStyle = context.fillStyle; context.fillStyle = "rgba(0, 0, 255, 0.10)"; context.fillRect(0, 0, element.width, element.height); @@ -136,39 +276,24 @@ export function renderElement( case "rectangle": case "diamond": case "ellipse": - generateElement(element, generator); - context.globalAlpha = element.opacity / 100; - rc.draw(element.shape as Drawable); - context.globalAlpha = 1; - break; case "line": - case "arrow": { - generateElement(element, generator); - context.globalAlpha = element.opacity / 100; - (element.shape as Drawable[]).forEach(shape => rc.draw(shape)); - context.globalAlpha = 1; + case "arrow": + case "text": { + generateElement(element, generator, sceneState); + + if (renderOptimizations) { + drawElementFromCanvas(element, rc, context, sceneState); + } else { + const offsetX = Math.floor(element.x + sceneState.scrollX); + const offsetY = Math.floor(element.y + sceneState.scrollY); + context.translate(offsetX, offsetY); + drawElementOnCanvas(element, rc, context); + context.translate(-offsetX, -offsetY); + } break; } default: { - if (isTextElement(element)) { - context.globalAlpha = element.opacity / 100; - const font = context.font; - context.font = element.font; - const fillStyle = context.fillStyle; - context.fillStyle = element.strokeColor; - // Canvas does not support multiline text by default - const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeight = element.height / lines.length; - const offset = element.height - element.baseline; - for (let i = 0; i < lines.length; i++) { - context.fillText(lines[i], 0, (i + 1) * lineHeight - offset); - } - context.fillStyle = fillStyle; - context.font = font; - context.globalAlpha = 1; - } else { - throw new Error(`Unimplemented type ${element.type}`); - } + throw new Error(`Unimplemented type ${element.type}`); } } } diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index d35db621..5d918c1b 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -1,6 +1,7 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughSVG } from "roughjs/bin/svg"; +import { FlooredNumber } from "../types"; import { ExcalidrawElement } from "../element/types"; import { getElementAbsoluteCoords, handlerRectangles } from "../element"; @@ -24,28 +25,22 @@ export function renderScene( sceneState: SceneState, // extra options, currently passed by export helper { - offsetX, - offsetY, renderScrollbars = true, renderSelection = true, + // Whether to employ render optimizations to improve performance. + // Should not be turned on for export operations and similar, because it + // doesn't guarantee pixel-perfect output. + renderOptimizations = false, }: { - offsetX?: number; - offsetY?: number; renderScrollbars?: boolean; renderSelection?: boolean; + renderOptimizations?: boolean; } = {}, ): boolean { if (!canvas) { return false; } - // Use offsets insteads of scrolls if available - sceneState = { - ...sceneState, - scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX, - scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY, - }; - const context = canvas.getContext("2d")!; // Get initial scale transform as reference for later usage @@ -57,8 +52,11 @@ export function renderScene( const normalizedCanvasHeight = canvas.height / getContextTransformScaleY(initialContextTransform); - // Handle zoom scaling - function scaleContextToZoom() { + const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom); + function applyZoom(context: CanvasRenderingContext2D): void { + context.save(); + + // Handle zoom scaling context.setTransform( getContextTransformScaleX(initialContextTransform) * sceneState.zoom, 0, @@ -67,11 +65,7 @@ export function renderScene( getContextTransformTranslateX(context.getTransform()), getContextTransformTranslateY(context.getTransform()), ); - } - - // Handle zoom translation - const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom); - function translateContextToZoom() { + // Handle zoom translation context.setTransform( getContextTransformScaleX(context.getTransform()), 0, @@ -83,6 +77,9 @@ export function renderScene( zoomTranslation.y, ); } + function resetZoom(context: CanvasRenderingContext2D): void { + context.restore(); + } // Paint background context.save(); @@ -111,27 +108,23 @@ export function renderScene( ), ); - context.save(); - scaleContextToZoom(); - translateContextToZoom(); - context.translate(sceneState.scrollX, sceneState.scrollY); + applyZoom(context); visibleElements.forEach(element => { - context.save(); - context.translate(element.x, element.y); - renderElement(element, rc, context); - context.restore(); + renderElement(element, rc, context, renderOptimizations, sceneState); }); - context.restore(); + resetZoom(context); // Pain selection element if (selectionElement) { - context.save(); - scaleContextToZoom(); - translateContextToZoom(); - context.translate(sceneState.scrollX, sceneState.scrollY); - context.translate(selectionElement.x, selectionElement.y); - renderElement(selectionElement, rc, context); - context.restore(); + applyZoom(context); + renderElement( + selectionElement, + rc, + context, + renderOptimizations, + sceneState, + ); + resetZoom(context); } // Pain selected elements @@ -139,9 +132,7 @@ export function renderScene( const selectedElements = getSelectedElements(elements); const dashledLinePadding = 4 / sceneState.zoom; - context.save(); - scaleContextToZoom(); - translateContextToZoom(); + applyZoom(context); context.translate(sceneState.scrollX, sceneState.scrollY); selectedElements.forEach(element => { const [ @@ -164,13 +155,11 @@ export function renderScene( ); context.setLineDash(initialLineDash); }); - context.restore(); + resetZoom(context); // Paint resize handlers if (selectedElements.length === 1 && selectedElements[0].type !== "text") { - context.save(); - scaleContextToZoom(); - translateContextToZoom(); + applyZoom(context); context.translate(sceneState.scrollX, sceneState.scrollY); const handlers = handlerRectangles(selectedElements[0], sceneState.zoom); Object.values(handlers) @@ -178,8 +167,10 @@ export function renderScene( .forEach(handler => { context.strokeRect(handler[0], handler[1], handler[2], handler[3]); }); - context.restore(); + resetZoom(context); } + + return visibleElements.length > 0; } // Paint scrollbars @@ -221,8 +212,8 @@ function isVisibleElement( scrollY, zoom, }: { - scrollX: number; - scrollY: number; + scrollX: FlooredNumber; + scrollY: FlooredNumber; zoom: number; }, ) { diff --git a/src/scene/data.ts b/src/scene/data.ts index bb296782..19c2430d 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -6,7 +6,7 @@ import { clearAppStateForLocalStorage, } from "../appState"; -import { AppState } from "../types"; +import { AppState, FlooredNumber } from "../types"; import { ExportType } from "./types"; import { exportToCanvas, exportToSvg } from "./export"; import nanoid from "nanoid"; @@ -59,17 +59,21 @@ export function serializeAsJSON( ); } +export function normalizeScroll(pos: number) { + return Math.floor(pos) as FlooredNumber; +} + export function calculateScrollCenter( elements: readonly ExcalidrawElement[], -): { scrollX: number; scrollY: number } { +): { scrollX: FlooredNumber; scrollY: FlooredNumber } { const [x1, y1, x2, y2] = getCommonBounds(elements); const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; return { - scrollX: window.innerWidth / 2 - centerX, - scrollY: window.innerHeight / 2 - centerY, + scrollX: normalizeScroll(window.innerWidth / 2 - centerX), + scrollY: normalizeScroll(window.innerHeight / 2 - centerY), }; } @@ -383,6 +387,10 @@ function restore( ? 100 : element.opacity, points, + shape: null, + canvas: null, + canvasOffsetX: element.canvasOffsetX || 0, + canvasOffsetY: element.canvasOffsetY || 0, }; }); @@ -430,7 +438,9 @@ export function saveToLocalStorage( localStorage.setItem( LOCAL_STORAGE_KEY, JSON.stringify( - elements.map(({ shape, ...element }: ExcalidrawElement) => element), + elements.map( + ({ shape, canvas, ...element }: ExcalidrawElement) => element, + ), ), ); localStorage.setItem( diff --git a/src/scene/export.ts b/src/scene/export.ts index c7df1df7..87abe183 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -3,6 +3,7 @@ import { ExcalidrawElement } from "../element/types"; import { getCommonBounds } from "../element/bounds"; import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; import { distance, SVG_NS } from "../utils"; +import { normalizeScroll } from "./data"; export function exportToCanvas( elements: readonly ExcalidrawElement[], @@ -42,15 +43,14 @@ export function exportToCanvas( tempCanvas, { viewBackgroundColor: exportBackground ? viewBackgroundColor : null, - scrollX: 0, - scrollY: 0, + scrollX: normalizeScroll(-minX + exportPadding), + scrollY: normalizeScroll(-minY + exportPadding), zoom: 1, }, { - offsetX: -minX + exportPadding, - offsetY: -minY + exportPadding, renderScrollbars: false, renderSelection: false, + renderOptimizations: false, }, ); return tempCanvas; diff --git a/src/scene/scrollbars.ts b/src/scene/scrollbars.ts index eec2ca06..2528a1ea 100644 --- a/src/scene/scrollbars.ts +++ b/src/scene/scrollbars.ts @@ -1,5 +1,6 @@ import { ExcalidrawElement } from "../element/types"; import { getCommonBounds } from "../element"; +import { FlooredNumber } from "../types"; const SCROLLBAR_MARGIN = 4; export const SCROLLBAR_WIDTH = 6; @@ -14,8 +15,8 @@ export function getScrollBars( scrollY, zoom, }: { - scrollX: number; - scrollY: number; + scrollX: FlooredNumber; + scrollY: FlooredNumber; zoom: number; }, ) { @@ -93,8 +94,8 @@ export function isOverScrollBars( scrollY, zoom, }: { - scrollX: number; - scrollY: number; + scrollX: FlooredNumber; + scrollY: FlooredNumber; zoom: number; }, ) { diff --git a/src/scene/types.ts b/src/scene/types.ts index aa093553..a1123e03 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -1,16 +1,17 @@ import { ExcalidrawTextElement } from "../element/types"; +import { FlooredNumber } from "../types"; export type SceneState = { - scrollX: number; - scrollY: number; + scrollX: FlooredNumber; + scrollY: FlooredNumber; // null indicates transparent bg viewBackgroundColor: string | null; zoom: number; }; export type SceneScroll = { - scrollX: number; - scrollY: number; + scrollX: FlooredNumber; + scrollY: FlooredNumber; }; export interface Scene { diff --git a/src/styles.scss b/src/styles.scss index c9ec3452..17cbf318 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -6,6 +6,15 @@ body { color: var(--text-color-primary); } +canvas { + // following props improve blurriness at certain devicePixelRatios. + // AFAIK it doesn't affect export (in fact, export seems sharp either way). + + image-rendering: pixelated; // chromium + // NOTE: must be declared *after* the above + image-rendering: -moz-crisp-edges; // FF +} + .container { display: flex; position: fixed; diff --git a/src/types.ts b/src/types.ts index 4a6e80df..5c8f4433 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ import { ExcalidrawElement } from "./element/types"; import { SHAPES } from "./shapes"; +export type FlooredNumber = number & { _brand: "FlooredNumber" }; + export type AppState = { draggingElement: ExcalidrawElement | null; resizingElement: ExcalidrawElement | null; @@ -20,8 +22,8 @@ export type AppState = { currentItemOpacity: number; currentItemFont: string; viewBackgroundColor: string; - scrollX: number; - scrollY: number; + scrollX: FlooredNumber; + scrollY: FlooredNumber; cursorX: number; cursorY: number; scrolledOutside: boolean;