mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 02:44:50 +01:00 
			
		
		
		
	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 <luzar.david@gmail.com> Co-authored-by: Preet <833927+pshihn@users.noreply.github.com>
This commit is contained in:
		 Christopher Chedeau
					Christopher Chedeau
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							d39c7d4421
						
					
				
				
					commit
					5256096d76
				
			| @@ -1,4 +1,4 @@ | |||||||
| import { AppState } from "./types"; | import { AppState, FlooredNumber } from "./types"; | ||||||
| import { getDateTime } from "./utils"; | import { getDateTime } from "./utils"; | ||||||
|  |  | ||||||
| const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; | const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; | ||||||
| @@ -20,8 +20,8 @@ export function getDefaultAppState(): AppState { | |||||||
|     currentItemOpacity: 100, |     currentItemOpacity: 100, | ||||||
|     currentItemFont: "20px Virgil", |     currentItemFont: "20px Virgil", | ||||||
|     viewBackgroundColor: "#ffffff", |     viewBackgroundColor: "#ffffff", | ||||||
|     scrollX: 0, |     scrollX: 0 as FlooredNumber, | ||||||
|     scrollY: 0, |     scrollY: 0 as FlooredNumber, | ||||||
|     cursorX: 0, |     cursorX: 0, | ||||||
|     cursorY: 0, |     cursorY: 0, | ||||||
|     scrolledOutside: false, |     scrolledOutside: false, | ||||||
|   | |||||||
| @@ -20,7 +20,7 @@ export async function copyToAppClipboard( | |||||||
|   elements: readonly ExcalidrawElement[], |   elements: readonly ExcalidrawElement[], | ||||||
| ) { | ) { | ||||||
|   CLIPBOARD = JSON.stringify( |   CLIPBOARD = JSON.stringify( | ||||||
|     getSelectedElements(elements).map(({ shape, ...el }) => el), |     getSelectedElements(elements).map(({ shape, canvas, ...el }) => el), | ||||||
|   ); |   ); | ||||||
|   try { |   try { | ||||||
|     // when copying to in-app clipboard, clear system clipboard so that if |     // when copying to in-app clipboard, clear system clipboard so that if | ||||||
|   | |||||||
| @@ -36,6 +36,10 @@ export function newElement( | |||||||
|     seed: randomSeed(), |     seed: randomSeed(), | ||||||
|     shape: null as Drawable | Drawable[] | null, |     shape: null as Drawable | Drawable[] | null, | ||||||
|     points: [] as Point[], |     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; |   return element; | ||||||
| } | } | ||||||
| @@ -48,6 +52,7 @@ export function newTextElement( | |||||||
|   const metrics = measureText(text, font); |   const metrics = measureText(text, font); | ||||||
|   const textElement: ExcalidrawTextElement = { |   const textElement: ExcalidrawTextElement = { | ||||||
|     ...element, |     ...element, | ||||||
|  |     shape: null, | ||||||
|     type: "text", |     type: "text", | ||||||
|     text: text, |     text: text, | ||||||
|     font: font, |     font: font, | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ class SceneHistory { | |||||||
|       elements: elements.map(({ shape, ...element }) => ({ |       elements: elements.map(({ shape, ...element }) => ({ | ||||||
|         ...element, |         ...element, | ||||||
|         shape: null, |         shape: null, | ||||||
|  |         canvas: null, | ||||||
|         points: |         points: | ||||||
|           appState.multiElement && appState.multiElement.id === element.id |           appState.multiElement && appState.multiElement.id === element.id | ||||||
|             ? element.points.slice(0, -1) |             ? element.points.slice(0, -1) | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ import { | |||||||
| } from "./scene"; | } from "./scene"; | ||||||
|  |  | ||||||
| import { renderScene } from "./renderer"; | import { renderScene } from "./renderer"; | ||||||
| import { AppState } from "./types"; | import { AppState, FlooredNumber } from "./types"; | ||||||
| import { ExcalidrawElement } from "./element/types"; | import { ExcalidrawElement } from "./element/types"; | ||||||
|  |  | ||||||
| import { | import { | ||||||
| @@ -106,6 +106,7 @@ import { t, languages, setLanguage, getLanguage } from "./i18n"; | |||||||
| import { HintViewer } from "./components/HintViewer"; | import { HintViewer } from "./components/HintViewer"; | ||||||
|  |  | ||||||
| import { copyToAppClipboard, getClipboardContent } from "./clipboard"; | import { copyToAppClipboard, getClipboardContent } from "./clipboard"; | ||||||
|  | import { normalizeScroll } from "./scene/data"; | ||||||
|  |  | ||||||
| let { elements } = createScene(); | let { elements } = createScene(); | ||||||
| const { history } = createHistory(); | const { history } = createHistory(); | ||||||
| @@ -143,8 +144,8 @@ export function viewportCoordsToSceneCoords( | |||||||
|     scrollY, |     scrollY, | ||||||
|     zoom, |     zoom, | ||||||
|   }: { |   }: { | ||||||
|     scrollX: number; |     scrollX: FlooredNumber; | ||||||
|     scrollY: number; |     scrollY: FlooredNumber; | ||||||
|     zoom: number; |     zoom: number; | ||||||
|   }, |   }, | ||||||
|   canvas: HTMLCanvasElement | null, |   canvas: HTMLCanvasElement | null, | ||||||
| @@ -166,8 +167,8 @@ export function sceneCoordsToViewportCoords( | |||||||
|     scrollY, |     scrollY, | ||||||
|     zoom, |     zoom, | ||||||
|   }: { |   }: { | ||||||
|     scrollX: number; |     scrollX: FlooredNumber; | ||||||
|     scrollY: number; |     scrollY: FlooredNumber; | ||||||
|     zoom: number; |     zoom: number; | ||||||
|   }, |   }, | ||||||
|   canvas: HTMLCanvasElement | null, |   canvas: HTMLCanvasElement | null, | ||||||
| @@ -651,6 +652,7 @@ export class App extends React.Component<any, AppState> { | |||||||
|   public state: AppState = getDefaultAppState(); |   public state: AppState = getDefaultAppState(); | ||||||
|  |  | ||||||
|   private onResize = () => { |   private onResize = () => { | ||||||
|  |     elements = elements.map(el => ({ ...el, shape: null })); | ||||||
|     this.setState({}); |     this.setState({}); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
| @@ -958,8 +960,12 @@ export class App extends React.Component<any, AppState> { | |||||||
|                   lastY = e.clientY; |                   lastY = e.clientY; | ||||||
|  |  | ||||||
|                   this.setState({ |                   this.setState({ | ||||||
|                     scrollX: this.state.scrollX - deltaX / this.state.zoom, |                     scrollX: normalizeScroll( | ||||||
|                     scrollY: this.state.scrollY - deltaY / this.state.zoom, |                       this.state.scrollX - deltaX / this.state.zoom, | ||||||
|  |                     ), | ||||||
|  |                     scrollY: normalizeScroll( | ||||||
|  |                       this.state.scrollY - deltaY / this.state.zoom, | ||||||
|  |                     ), | ||||||
|                   }); |                   }); | ||||||
|                 }; |                 }; | ||||||
|                 const teardown = (lastMouseUp = () => { |                 const teardown = (lastMouseUp = () => { | ||||||
| @@ -1294,7 +1300,9 @@ export class App extends React.Component<any, AppState> { | |||||||
|                   const x = e.clientX; |                   const x = e.clientX; | ||||||
|                   const dx = x - lastX; |                   const dx = x - lastX; | ||||||
|                   this.setState({ |                   this.setState({ | ||||||
|                     scrollX: this.state.scrollX - dx / this.state.zoom, |                     scrollX: normalizeScroll( | ||||||
|  |                       this.state.scrollX - dx / this.state.zoom, | ||||||
|  |                     ), | ||||||
|                   }); |                   }); | ||||||
|                   lastX = x; |                   lastX = x; | ||||||
|                   return; |                   return; | ||||||
| @@ -1304,7 +1312,9 @@ export class App extends React.Component<any, AppState> { | |||||||
|                   const y = e.clientY; |                   const y = e.clientY; | ||||||
|                   const dy = y - lastY; |                   const dy = y - lastY; | ||||||
|                   this.setState({ |                   this.setState({ | ||||||
|                     scrollY: this.state.scrollY - dy / this.state.zoom, |                     scrollY: normalizeScroll( | ||||||
|  |                       this.state.scrollY - dy / this.state.zoom, | ||||||
|  |                     ), | ||||||
|                   }); |                   }); | ||||||
|                   lastY = y; |                   lastY = y; | ||||||
|                   return; |                   return; | ||||||
| @@ -2004,8 +2014,8 @@ export class App extends React.Component<any, AppState> { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     this.setState(({ zoom, scrollX, scrollY }) => ({ |     this.setState(({ zoom, scrollX, scrollY }) => ({ | ||||||
|       scrollX: scrollX - deltaX / zoom, |       scrollX: normalizeScroll(scrollX - deltaX / zoom), | ||||||
|       scrollY: scrollY - deltaY / zoom, |       scrollY: normalizeScroll(scrollY - deltaY / zoom), | ||||||
|     })); |     })); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
| @@ -2069,10 +2079,7 @@ export class App extends React.Component<any, AppState> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   private saveDebounced = debounce(() => { |   private saveDebounced = debounce(() => { | ||||||
|     saveToLocalStorage( |     saveToLocalStorage(elements, this.state); | ||||||
|       elements.filter(x => x.type !== "selection"), |  | ||||||
|       this.state, |  | ||||||
|     ); |  | ||||||
|   }, 300); |   }, 300); | ||||||
|  |  | ||||||
|   componentDidUpdate() { |   componentDidUpdate() { | ||||||
| @@ -2087,6 +2094,9 @@ export class App extends React.Component<any, AppState> { | |||||||
|         viewBackgroundColor: this.state.viewBackgroundColor, |         viewBackgroundColor: this.state.viewBackgroundColor, | ||||||
|         zoom: this.state.zoom, |         zoom: this.state.zoom, | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         renderOptimizations: true, | ||||||
|  |       }, | ||||||
|     ); |     ); | ||||||
|     const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0; |     const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0; | ||||||
|     if (this.state.scrolledOutside !== scrolledOutside) { |     if (this.state.scrolledOutside !== scrolledOutside) { | ||||||
|   | |||||||
| @@ -1,18 +1,111 @@ | |||||||
| import { ExcalidrawElement } from "../element/types"; | import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; | ||||||
| import { isTextElement } from "../element/typeChecks"; | 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 { RoughCanvas } from "roughjs/bin/canvas"; | ||||||
| import { Drawable } from "roughjs/bin/core"; | import { Drawable } from "roughjs/bin/core"; | ||||||
| import { Point } from "roughjs/bin/geometry"; | import { Point } from "roughjs/bin/geometry"; | ||||||
| import { RoughSVG } from "roughjs/bin/svg"; | import { RoughSVG } from "roughjs/bin/svg"; | ||||||
| import { RoughGenerator } from "roughjs/bin/generator"; | 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( | function generateElement( | ||||||
|   element: ExcalidrawElement, |   element: ExcalidrawElement, | ||||||
|   generator: RoughGenerator, |   generator: RoughGenerator, | ||||||
|  |   sceneState?: SceneState, | ||||||
| ) { | ) { | ||||||
|   if (!element.shape) { |   if (!element.shape) { | ||||||
|  |     element.canvas = null; | ||||||
|     switch (element.type) { |     switch (element.type) { | ||||||
|       case "rectangle": |       case "rectangle": | ||||||
|         element.shape = generator.rectangle( |         element.shape = generator.rectangle( | ||||||
| @@ -32,6 +125,7 @@ function generateElement( | |||||||
|             seed: element.seed, |             seed: element.seed, | ||||||
|           }, |           }, | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         break; |         break; | ||||||
|       case "diamond": { |       case "diamond": { | ||||||
|         const [ |         const [ | ||||||
| @@ -115,18 +209,64 @@ function generateElement( | |||||||
|         } |         } | ||||||
|         break; |         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( | export function renderElement( | ||||||
|   element: ExcalidrawElement, |   element: ExcalidrawElement, | ||||||
|   rc: RoughCanvas, |   rc: RoughCanvas, | ||||||
|   context: CanvasRenderingContext2D, |   context: CanvasRenderingContext2D, | ||||||
|  |   renderOptimizations: boolean, | ||||||
|  |   sceneState: SceneState, | ||||||
| ) { | ) { | ||||||
|   const generator = rc.generator; |   const generator = rc.generator; | ||||||
|   switch (element.type) { |   switch (element.type) { | ||||||
|     case "selection": { |     case "selection": { | ||||||
|  |       context.translate( | ||||||
|  |         element.x + sceneState.scrollX, | ||||||
|  |         element.y + sceneState.scrollY, | ||||||
|  |       ); | ||||||
|       const fillStyle = context.fillStyle; |       const fillStyle = context.fillStyle; | ||||||
|       context.fillStyle = "rgba(0, 0, 255, 0.10)"; |       context.fillStyle = "rgba(0, 0, 255, 0.10)"; | ||||||
|       context.fillRect(0, 0, element.width, element.height); |       context.fillRect(0, 0, element.width, element.height); | ||||||
| @@ -136,39 +276,24 @@ export function renderElement( | |||||||
|     case "rectangle": |     case "rectangle": | ||||||
|     case "diamond": |     case "diamond": | ||||||
|     case "ellipse": |     case "ellipse": | ||||||
|       generateElement(element, generator); |  | ||||||
|       context.globalAlpha = element.opacity / 100; |  | ||||||
|       rc.draw(element.shape as Drawable); |  | ||||||
|       context.globalAlpha = 1; |  | ||||||
|       break; |  | ||||||
|     case "line": |     case "line": | ||||||
|     case "arrow": { |     case "arrow": | ||||||
|       generateElement(element, generator); |     case "text": { | ||||||
|       context.globalAlpha = element.opacity / 100; |       generateElement(element, generator, sceneState); | ||||||
|       (element.shape as Drawable[]).forEach(shape => rc.draw(shape)); |  | ||||||
|       context.globalAlpha = 1; |       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; |       break; | ||||||
|     } |     } | ||||||
|     default: { |     default: { | ||||||
|       if (isTextElement(element)) { |       throw new Error(`Unimplemented type ${element.type}`); | ||||||
|         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}`); |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import { RoughCanvas } from "roughjs/bin/canvas"; | import { RoughCanvas } from "roughjs/bin/canvas"; | ||||||
| import { RoughSVG } from "roughjs/bin/svg"; | import { RoughSVG } from "roughjs/bin/svg"; | ||||||
|  |  | ||||||
|  | import { FlooredNumber } from "../types"; | ||||||
| import { ExcalidrawElement } from "../element/types"; | import { ExcalidrawElement } from "../element/types"; | ||||||
| import { getElementAbsoluteCoords, handlerRectangles } from "../element"; | import { getElementAbsoluteCoords, handlerRectangles } from "../element"; | ||||||
|  |  | ||||||
| @@ -24,28 +25,22 @@ export function renderScene( | |||||||
|   sceneState: SceneState, |   sceneState: SceneState, | ||||||
|   // extra options, currently passed by export helper |   // extra options, currently passed by export helper | ||||||
|   { |   { | ||||||
|     offsetX, |  | ||||||
|     offsetY, |  | ||||||
|     renderScrollbars = true, |     renderScrollbars = true, | ||||||
|     renderSelection = 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; |     renderScrollbars?: boolean; | ||||||
|     renderSelection?: boolean; |     renderSelection?: boolean; | ||||||
|  |     renderOptimizations?: boolean; | ||||||
|   } = {}, |   } = {}, | ||||||
| ): boolean { | ): boolean { | ||||||
|   if (!canvas) { |   if (!canvas) { | ||||||
|     return false; |     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")!; |   const context = canvas.getContext("2d")!; | ||||||
|  |  | ||||||
|   // Get initial scale transform as reference for later usage |   // Get initial scale transform as reference for later usage | ||||||
| @@ -57,8 +52,11 @@ export function renderScene( | |||||||
|   const normalizedCanvasHeight = |   const normalizedCanvasHeight = | ||||||
|     canvas.height / getContextTransformScaleY(initialContextTransform); |     canvas.height / getContextTransformScaleY(initialContextTransform); | ||||||
|  |  | ||||||
|   // Handle zoom scaling |   const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom); | ||||||
|   function scaleContextToZoom() { |   function applyZoom(context: CanvasRenderingContext2D): void { | ||||||
|  |     context.save(); | ||||||
|  |  | ||||||
|  |     // Handle zoom scaling | ||||||
|     context.setTransform( |     context.setTransform( | ||||||
|       getContextTransformScaleX(initialContextTransform) * sceneState.zoom, |       getContextTransformScaleX(initialContextTransform) * sceneState.zoom, | ||||||
|       0, |       0, | ||||||
| @@ -67,11 +65,7 @@ export function renderScene( | |||||||
|       getContextTransformTranslateX(context.getTransform()), |       getContextTransformTranslateX(context.getTransform()), | ||||||
|       getContextTransformTranslateY(context.getTransform()), |       getContextTransformTranslateY(context.getTransform()), | ||||||
|     ); |     ); | ||||||
|   } |     // Handle zoom translation | ||||||
|  |  | ||||||
|   // Handle zoom translation |  | ||||||
|   const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom); |  | ||||||
|   function translateContextToZoom() { |  | ||||||
|     context.setTransform( |     context.setTransform( | ||||||
|       getContextTransformScaleX(context.getTransform()), |       getContextTransformScaleX(context.getTransform()), | ||||||
|       0, |       0, | ||||||
| @@ -83,6 +77,9 @@ export function renderScene( | |||||||
|         zoomTranslation.y, |         zoomTranslation.y, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |   function resetZoom(context: CanvasRenderingContext2D): void { | ||||||
|  |     context.restore(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   // Paint background |   // Paint background | ||||||
|   context.save(); |   context.save(); | ||||||
| @@ -111,27 +108,23 @@ export function renderScene( | |||||||
|     ), |     ), | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   context.save(); |   applyZoom(context); | ||||||
|   scaleContextToZoom(); |  | ||||||
|   translateContextToZoom(); |  | ||||||
|   context.translate(sceneState.scrollX, sceneState.scrollY); |  | ||||||
|   visibleElements.forEach(element => { |   visibleElements.forEach(element => { | ||||||
|     context.save(); |     renderElement(element, rc, context, renderOptimizations, sceneState); | ||||||
|     context.translate(element.x, element.y); |  | ||||||
|     renderElement(element, rc, context); |  | ||||||
|     context.restore(); |  | ||||||
|   }); |   }); | ||||||
|   context.restore(); |   resetZoom(context); | ||||||
|  |  | ||||||
|   // Pain selection element |   // Pain selection element | ||||||
|   if (selectionElement) { |   if (selectionElement) { | ||||||
|     context.save(); |     applyZoom(context); | ||||||
|     scaleContextToZoom(); |     renderElement( | ||||||
|     translateContextToZoom(); |       selectionElement, | ||||||
|     context.translate(sceneState.scrollX, sceneState.scrollY); |       rc, | ||||||
|     context.translate(selectionElement.x, selectionElement.y); |       context, | ||||||
|     renderElement(selectionElement, rc, context); |       renderOptimizations, | ||||||
|     context.restore(); |       sceneState, | ||||||
|  |     ); | ||||||
|  |     resetZoom(context); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Pain selected elements |   // Pain selected elements | ||||||
| @@ -139,9 +132,7 @@ export function renderScene( | |||||||
|     const selectedElements = getSelectedElements(elements); |     const selectedElements = getSelectedElements(elements); | ||||||
|     const dashledLinePadding = 4 / sceneState.zoom; |     const dashledLinePadding = 4 / sceneState.zoom; | ||||||
|  |  | ||||||
|     context.save(); |     applyZoom(context); | ||||||
|     scaleContextToZoom(); |  | ||||||
|     translateContextToZoom(); |  | ||||||
|     context.translate(sceneState.scrollX, sceneState.scrollY); |     context.translate(sceneState.scrollX, sceneState.scrollY); | ||||||
|     selectedElements.forEach(element => { |     selectedElements.forEach(element => { | ||||||
|       const [ |       const [ | ||||||
| @@ -164,13 +155,11 @@ export function renderScene( | |||||||
|       ); |       ); | ||||||
|       context.setLineDash(initialLineDash); |       context.setLineDash(initialLineDash); | ||||||
|     }); |     }); | ||||||
|     context.restore(); |     resetZoom(context); | ||||||
|  |  | ||||||
|     // Paint resize handlers |     // Paint resize handlers | ||||||
|     if (selectedElements.length === 1 && selectedElements[0].type !== "text") { |     if (selectedElements.length === 1 && selectedElements[0].type !== "text") { | ||||||
|       context.save(); |       applyZoom(context); | ||||||
|       scaleContextToZoom(); |  | ||||||
|       translateContextToZoom(); |  | ||||||
|       context.translate(sceneState.scrollX, sceneState.scrollY); |       context.translate(sceneState.scrollX, sceneState.scrollY); | ||||||
|       const handlers = handlerRectangles(selectedElements[0], sceneState.zoom); |       const handlers = handlerRectangles(selectedElements[0], sceneState.zoom); | ||||||
|       Object.values(handlers) |       Object.values(handlers) | ||||||
| @@ -178,8 +167,10 @@ export function renderScene( | |||||||
|         .forEach(handler => { |         .forEach(handler => { | ||||||
|           context.strokeRect(handler[0], handler[1], handler[2], handler[3]); |           context.strokeRect(handler[0], handler[1], handler[2], handler[3]); | ||||||
|         }); |         }); | ||||||
|       context.restore(); |       resetZoom(context); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     return visibleElements.length > 0; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Paint scrollbars |   // Paint scrollbars | ||||||
| @@ -221,8 +212,8 @@ function isVisibleElement( | |||||||
|     scrollY, |     scrollY, | ||||||
|     zoom, |     zoom, | ||||||
|   }: { |   }: { | ||||||
|     scrollX: number; |     scrollX: FlooredNumber; | ||||||
|     scrollY: number; |     scrollY: FlooredNumber; | ||||||
|     zoom: number; |     zoom: number; | ||||||
|   }, |   }, | ||||||
| ) { | ) { | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ import { | |||||||
|   clearAppStateForLocalStorage, |   clearAppStateForLocalStorage, | ||||||
| } from "../appState"; | } from "../appState"; | ||||||
|  |  | ||||||
| import { AppState } from "../types"; | import { AppState, FlooredNumber } from "../types"; | ||||||
| import { ExportType } from "./types"; | import { ExportType } from "./types"; | ||||||
| import { exportToCanvas, exportToSvg } from "./export"; | import { exportToCanvas, exportToSvg } from "./export"; | ||||||
| import nanoid from "nanoid"; | 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( | export function calculateScrollCenter( | ||||||
|   elements: readonly ExcalidrawElement[], |   elements: readonly ExcalidrawElement[], | ||||||
| ): { scrollX: number; scrollY: number } { | ): { scrollX: FlooredNumber; scrollY: FlooredNumber } { | ||||||
|   const [x1, y1, x2, y2] = getCommonBounds(elements); |   const [x1, y1, x2, y2] = getCommonBounds(elements); | ||||||
|  |  | ||||||
|   const centerX = (x1 + x2) / 2; |   const centerX = (x1 + x2) / 2; | ||||||
|   const centerY = (y1 + y2) / 2; |   const centerY = (y1 + y2) / 2; | ||||||
|  |  | ||||||
|   return { |   return { | ||||||
|     scrollX: window.innerWidth / 2 - centerX, |     scrollX: normalizeScroll(window.innerWidth / 2 - centerX), | ||||||
|     scrollY: window.innerHeight / 2 - centerY, |     scrollY: normalizeScroll(window.innerHeight / 2 - centerY), | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -383,6 +387,10 @@ function restore( | |||||||
|             ? 100 |             ? 100 | ||||||
|             : element.opacity, |             : element.opacity, | ||||||
|         points, |         points, | ||||||
|  |         shape: null, | ||||||
|  |         canvas: null, | ||||||
|  |         canvasOffsetX: element.canvasOffsetX || 0, | ||||||
|  |         canvasOffsetY: element.canvasOffsetY || 0, | ||||||
|       }; |       }; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
| @@ -430,7 +438,9 @@ export function saveToLocalStorage( | |||||||
|   localStorage.setItem( |   localStorage.setItem( | ||||||
|     LOCAL_STORAGE_KEY, |     LOCAL_STORAGE_KEY, | ||||||
|     JSON.stringify( |     JSON.stringify( | ||||||
|       elements.map(({ shape, ...element }: ExcalidrawElement) => element), |       elements.map( | ||||||
|  |         ({ shape, canvas, ...element }: ExcalidrawElement) => element, | ||||||
|  |       ), | ||||||
|     ), |     ), | ||||||
|   ); |   ); | ||||||
|   localStorage.setItem( |   localStorage.setItem( | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import { ExcalidrawElement } from "../element/types"; | |||||||
| import { getCommonBounds } from "../element/bounds"; | import { getCommonBounds } from "../element/bounds"; | ||||||
| import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; | import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; | ||||||
| import { distance, SVG_NS } from "../utils"; | import { distance, SVG_NS } from "../utils"; | ||||||
|  | import { normalizeScroll } from "./data"; | ||||||
|  |  | ||||||
| export function exportToCanvas( | export function exportToCanvas( | ||||||
|   elements: readonly ExcalidrawElement[], |   elements: readonly ExcalidrawElement[], | ||||||
| @@ -42,15 +43,14 @@ export function exportToCanvas( | |||||||
|     tempCanvas, |     tempCanvas, | ||||||
|     { |     { | ||||||
|       viewBackgroundColor: exportBackground ? viewBackgroundColor : null, |       viewBackgroundColor: exportBackground ? viewBackgroundColor : null, | ||||||
|       scrollX: 0, |       scrollX: normalizeScroll(-minX + exportPadding), | ||||||
|       scrollY: 0, |       scrollY: normalizeScroll(-minY + exportPadding), | ||||||
|       zoom: 1, |       zoom: 1, | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       offsetX: -minX + exportPadding, |  | ||||||
|       offsetY: -minY + exportPadding, |  | ||||||
|       renderScrollbars: false, |       renderScrollbars: false, | ||||||
|       renderSelection: false, |       renderSelection: false, | ||||||
|  |       renderOptimizations: false, | ||||||
|     }, |     }, | ||||||
|   ); |   ); | ||||||
|   return tempCanvas; |   return tempCanvas; | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { ExcalidrawElement } from "../element/types"; | import { ExcalidrawElement } from "../element/types"; | ||||||
| import { getCommonBounds } from "../element"; | import { getCommonBounds } from "../element"; | ||||||
|  | import { FlooredNumber } from "../types"; | ||||||
|  |  | ||||||
| const SCROLLBAR_MARGIN = 4; | const SCROLLBAR_MARGIN = 4; | ||||||
| export const SCROLLBAR_WIDTH = 6; | export const SCROLLBAR_WIDTH = 6; | ||||||
| @@ -14,8 +15,8 @@ export function getScrollBars( | |||||||
|     scrollY, |     scrollY, | ||||||
|     zoom, |     zoom, | ||||||
|   }: { |   }: { | ||||||
|     scrollX: number; |     scrollX: FlooredNumber; | ||||||
|     scrollY: number; |     scrollY: FlooredNumber; | ||||||
|     zoom: number; |     zoom: number; | ||||||
|   }, |   }, | ||||||
| ) { | ) { | ||||||
| @@ -93,8 +94,8 @@ export function isOverScrollBars( | |||||||
|     scrollY, |     scrollY, | ||||||
|     zoom, |     zoom, | ||||||
|   }: { |   }: { | ||||||
|     scrollX: number; |     scrollX: FlooredNumber; | ||||||
|     scrollY: number; |     scrollY: FlooredNumber; | ||||||
|     zoom: number; |     zoom: number; | ||||||
|   }, |   }, | ||||||
| ) { | ) { | ||||||
|   | |||||||
| @@ -1,16 +1,17 @@ | |||||||
| import { ExcalidrawTextElement } from "../element/types"; | import { ExcalidrawTextElement } from "../element/types"; | ||||||
|  | import { FlooredNumber } from "../types"; | ||||||
|  |  | ||||||
| export type SceneState = { | export type SceneState = { | ||||||
|   scrollX: number; |   scrollX: FlooredNumber; | ||||||
|   scrollY: number; |   scrollY: FlooredNumber; | ||||||
|   // null indicates transparent bg |   // null indicates transparent bg | ||||||
|   viewBackgroundColor: string | null; |   viewBackgroundColor: string | null; | ||||||
|   zoom: number; |   zoom: number; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export type SceneScroll = { | export type SceneScroll = { | ||||||
|   scrollX: number; |   scrollX: FlooredNumber; | ||||||
|   scrollY: number; |   scrollY: FlooredNumber; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export interface Scene { | export interface Scene { | ||||||
|   | |||||||
| @@ -6,6 +6,15 @@ body { | |||||||
|   color: var(--text-color-primary); |   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 { | .container { | ||||||
|   display: flex; |   display: flex; | ||||||
|   position: fixed; |   position: fixed; | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import { ExcalidrawElement } from "./element/types"; | import { ExcalidrawElement } from "./element/types"; | ||||||
| import { SHAPES } from "./shapes"; | import { SHAPES } from "./shapes"; | ||||||
|  |  | ||||||
|  | export type FlooredNumber = number & { _brand: "FlooredNumber" }; | ||||||
|  |  | ||||||
| export type AppState = { | export type AppState = { | ||||||
|   draggingElement: ExcalidrawElement | null; |   draggingElement: ExcalidrawElement | null; | ||||||
|   resizingElement: ExcalidrawElement | null; |   resizingElement: ExcalidrawElement | null; | ||||||
| @@ -20,8 +22,8 @@ export type AppState = { | |||||||
|   currentItemOpacity: number; |   currentItemOpacity: number; | ||||||
|   currentItemFont: string; |   currentItemFont: string; | ||||||
|   viewBackgroundColor: string; |   viewBackgroundColor: string; | ||||||
|   scrollX: number; |   scrollX: FlooredNumber; | ||||||
|   scrollY: number; |   scrollY: FlooredNumber; | ||||||
|   cursorX: number; |   cursorX: number; | ||||||
|   cursorY: number; |   cursorY: number; | ||||||
|   scrolledOutside: boolean; |   scrolledOutside: boolean; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user