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:
Enzo Ferey 2020-02-15 21:03:32 +01:00 committed by GitHub
parent dd2d7e1a88
commit c7ff4c2ed6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 612 additions and 272 deletions

View file

@ -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;
}