mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Canvas zooming (#716)
* Zoom icons. * Actions. * Min zoom of 0 does not make sense. * Zoom logic. * Modify how zoom affects selection rendering. * More precise scrollbar dimensions. * Adjust elements visibility and scrollbars. * Normalized canvas width and height. * Apply zoom to resize test. * [WIP] Zoom using canvas center as an origin. * Undo zoom on `getScrollBars`. * WIP: center zoom origin via scroll * This was wrong for sure. * Finish scaling using center as origin. * Almost there. * Scroll offset should be not part of zoom transforms. * Better naming. * Wheel movement should be the same no matter the zoom level. * Panning movement should be the same no matter the zoom level. * Fix elements pasting. * Fix text WYSIWGT. * Fix scrollbars and visibility.
This commit is contained in:
parent
dd2d7e1a88
commit
c7ff4c2ed6
19 changed files with 612 additions and 272 deletions
|
@ -11,6 +11,7 @@ import {
|
|||
SCROLLBAR_COLOR,
|
||||
SCROLLBAR_WIDTH,
|
||||
} from "../scene/scrollbars";
|
||||
import { getZoomTranslation } from "../scene/zoom";
|
||||
|
||||
import { renderElement, renderElementToSvg } from "./renderElement";
|
||||
|
||||
|
@ -32,116 +33,164 @@ export function renderScene(
|
|||
renderScrollbars?: boolean;
|
||||
renderSelection?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
): boolean {
|
||||
if (!canvas) {
|
||||
return false;
|
||||
}
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
const fillStyle = context.fillStyle;
|
||||
if (typeof sceneState.viewBackgroundColor === "string") {
|
||||
const hasTransparence =
|
||||
sceneState.viewBackgroundColor === "transparent" ||
|
||||
sceneState.viewBackgroundColor.length === 5 ||
|
||||
sceneState.viewBackgroundColor.length === 9;
|
||||
if (hasTransparence) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
context.fillStyle = sceneState.viewBackgroundColor;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
} else {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
context.fillStyle = fillStyle;
|
||||
|
||||
// Use offsets insteads of scrolls if available
|
||||
sceneState = {
|
||||
...sceneState,
|
||||
scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX,
|
||||
scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY,
|
||||
};
|
||||
|
||||
let atLeastOneVisibleElement = false;
|
||||
elements.forEach(element => {
|
||||
if (
|
||||
!isVisibleElement(
|
||||
element,
|
||||
sceneState.scrollX,
|
||||
sceneState.scrollY,
|
||||
// If canvas is scaled for high pixelDeviceRatio width and height
|
||||
// setted in the `style` attribute
|
||||
parseInt(canvas.style.width) || canvas.width,
|
||||
parseInt(canvas.style.height) || canvas.height,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
atLeastOneVisibleElement = true;
|
||||
context.translate(
|
||||
element.x + sceneState.scrollX,
|
||||
element.y + sceneState.scrollY,
|
||||
);
|
||||
renderElement(element, rc, context);
|
||||
context.translate(
|
||||
-element.x - sceneState.scrollX,
|
||||
-element.y - sceneState.scrollY,
|
||||
);
|
||||
});
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
if (selectionElement) {
|
||||
context.translate(
|
||||
selectionElement.x + sceneState.scrollX,
|
||||
selectionElement.y + sceneState.scrollY,
|
||||
);
|
||||
renderElement(selectionElement, rc, context);
|
||||
context.translate(
|
||||
-selectionElement.x - sceneState.scrollX,
|
||||
-selectionElement.y - sceneState.scrollY,
|
||||
// Get initial scale transform as reference for later usage
|
||||
const initialContextTransform = context.getTransform();
|
||||
|
||||
// When doing calculations based on canvas width we should used normalized one
|
||||
const normalizedCanvasWidth =
|
||||
canvas.width / getContextTransformScaleX(initialContextTransform);
|
||||
const normalizedCanvasHeight =
|
||||
canvas.height / getContextTransformScaleY(initialContextTransform);
|
||||
|
||||
// Handle zoom scaling
|
||||
function scaleContextToZoom() {
|
||||
context.setTransform(
|
||||
getContextTransformScaleX(initialContextTransform) * sceneState.zoom,
|
||||
0,
|
||||
0,
|
||||
getContextTransformScaleY(initialContextTransform) * sceneState.zoom,
|
||||
getContextTransformTranslateX(context.getTransform()),
|
||||
getContextTransformTranslateY(context.getTransform()),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle zoom translation
|
||||
const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom);
|
||||
function translateContextToZoom() {
|
||||
context.setTransform(
|
||||
getContextTransformScaleX(context.getTransform()),
|
||||
0,
|
||||
0,
|
||||
getContextTransformScaleY(context.getTransform()),
|
||||
getContextTransformTranslateX(initialContextTransform) -
|
||||
zoomTranslation.x,
|
||||
getContextTransformTranslateY(initialContextTransform) -
|
||||
zoomTranslation.y,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint background
|
||||
context.save();
|
||||
if (typeof sceneState.viewBackgroundColor === "string") {
|
||||
const hasTransparence =
|
||||
sceneState.viewBackgroundColor === "transparent" ||
|
||||
sceneState.viewBackgroundColor.length === 5 ||
|
||||
sceneState.viewBackgroundColor.length === 9;
|
||||
if (hasTransparence) {
|
||||
context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
|
||||
}
|
||||
context.fillStyle = sceneState.viewBackgroundColor;
|
||||
context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
|
||||
} else {
|
||||
context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
|
||||
}
|
||||
context.restore();
|
||||
|
||||
// Paint visible elements
|
||||
const visibleElements = elements.filter(element =>
|
||||
isVisibleElement(
|
||||
element,
|
||||
normalizedCanvasWidth,
|
||||
normalizedCanvasHeight,
|
||||
sceneState,
|
||||
),
|
||||
);
|
||||
|
||||
context.save();
|
||||
scaleContextToZoom();
|
||||
translateContextToZoom();
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
visibleElements.forEach(element => {
|
||||
context.save();
|
||||
context.translate(element.x, element.y);
|
||||
renderElement(element, rc, context);
|
||||
context.restore();
|
||||
});
|
||||
context.restore();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Pain selected elements
|
||||
if (renderSelection) {
|
||||
const selectedElements = elements.filter(el => el.isSelected);
|
||||
const selectedElements = elements.filter(element => element.isSelected);
|
||||
const dashledLinePadding = 4 / sceneState.zoom;
|
||||
|
||||
context.save();
|
||||
scaleContextToZoom();
|
||||
translateContextToZoom();
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
selectedElements.forEach(element => {
|
||||
const margin = 4;
|
||||
|
||||
const [
|
||||
elementX1,
|
||||
elementY1,
|
||||
elementX2,
|
||||
elementY2,
|
||||
] = getElementAbsoluteCoords(element);
|
||||
const lineDash = context.getLineDash();
|
||||
context.setLineDash([8, 4]);
|
||||
context.strokeRect(
|
||||
elementX1 - margin + sceneState.scrollX,
|
||||
elementY1 - margin + sceneState.scrollY,
|
||||
elementX2 - elementX1 + margin * 2,
|
||||
elementY2 - elementY1 + margin * 2,
|
||||
);
|
||||
context.setLineDash(lineDash);
|
||||
});
|
||||
|
||||
const elementWidth = elementX2 - elementX1;
|
||||
const elementHeight = elementY2 - elementY1;
|
||||
|
||||
const initialLineDash = context.getLineDash();
|
||||
context.setLineDash([8 / sceneState.zoom, 4 / sceneState.zoom]);
|
||||
context.strokeRect(
|
||||
elementX1 - dashledLinePadding,
|
||||
elementY1 - dashledLinePadding,
|
||||
elementWidth + dashledLinePadding * 2,
|
||||
elementHeight + dashledLinePadding * 2,
|
||||
);
|
||||
context.setLineDash(initialLineDash);
|
||||
});
|
||||
context.restore();
|
||||
|
||||
// Paint resize handlers
|
||||
if (selectedElements.length === 1 && selectedElements[0].type !== "text") {
|
||||
const handlers = handlerRectangles(selectedElements[0], sceneState);
|
||||
context.save();
|
||||
scaleContextToZoom();
|
||||
translateContextToZoom();
|
||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||
const handlers = handlerRectangles(selectedElements[0], sceneState.zoom);
|
||||
Object.values(handlers)
|
||||
.filter(handler => handler !== undefined)
|
||||
.forEach(handler => {
|
||||
context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Paint scrollbars
|
||||
if (renderScrollbars) {
|
||||
const scrollBars = getScrollBars(
|
||||
elements,
|
||||
context.canvas.width / window.devicePixelRatio,
|
||||
context.canvas.height / window.devicePixelRatio,
|
||||
sceneState.scrollX,
|
||||
sceneState.scrollY,
|
||||
normalizedCanvasWidth,
|
||||
normalizedCanvasHeight,
|
||||
sceneState,
|
||||
);
|
||||
|
||||
const strokeStyle = context.strokeStyle;
|
||||
context.save();
|
||||
context.fillStyle = SCROLLBAR_COLOR;
|
||||
context.strokeStyle = "rgba(255,255,255,0.8)";
|
||||
[scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => {
|
||||
|
@ -156,33 +205,40 @@ export function renderScene(
|
|||
);
|
||||
}
|
||||
});
|
||||
context.strokeStyle = strokeStyle;
|
||||
context.fillStyle = fillStyle;
|
||||
context.restore();
|
||||
}
|
||||
|
||||
return atLeastOneVisibleElement;
|
||||
return visibleElements.length > 0;
|
||||
}
|
||||
|
||||
function isVisibleElement(
|
||||
element: ExcalidrawElement,
|
||||
scrollX: number,
|
||||
scrollY: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
{
|
||||
scrollX,
|
||||
scrollY,
|
||||
zoom,
|
||||
}: {
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
zoom: number;
|
||||
},
|
||||
) {
|
||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
if (element.type !== "arrow") {
|
||||
x1 += scrollX;
|
||||
y1 += scrollY;
|
||||
x2 += scrollX;
|
||||
y2 += scrollY;
|
||||
return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
|
||||
}
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
|
||||
// Apply zoom
|
||||
const viewportWidthWithZoom = viewportWidth / zoom;
|
||||
const viewportHeightWithZoom = viewportHeight / zoom;
|
||||
|
||||
const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
|
||||
const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
|
||||
|
||||
return (
|
||||
x2 + scrollX >= 0 &&
|
||||
x1 + scrollX <= canvasWidth &&
|
||||
y2 + scrollY >= 0 &&
|
||||
y1 + scrollY <= canvasHeight
|
||||
x2 + scrollX - viewportWidthDiff / 2 >= 0 &&
|
||||
x1 + scrollX - viewportWidthDiff / 2 <= viewportWidthWithZoom &&
|
||||
y2 + scrollY - viewportHeightDiff / 2 >= 0 &&
|
||||
y1 + scrollY - viewportHeightDiff / 2 <= viewportHeightWithZoom
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -213,3 +269,16 @@ export function renderSceneToSvg(
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getContextTransformScaleX(transform: DOMMatrix): number {
|
||||
return transform.a;
|
||||
}
|
||||
function getContextTransformScaleY(transform: DOMMatrix): number {
|
||||
return transform.d;
|
||||
}
|
||||
function getContextTransformTranslateX(transform: DOMMatrix): number {
|
||||
return transform.e;
|
||||
}
|
||||
function getContextTransformTranslateY(transform: DOMMatrix): number {
|
||||
return transform.f;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue