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

@ -34,6 +34,7 @@ import {
loadScene,
calculateScrollCenter,
loadFromBlob,
getZoomOrigin,
} from "./scene";
import { renderScene } from "./renderer";
@ -77,6 +78,8 @@ import {
actionChangeFontFamily,
actionChangeViewBackgroundColor,
actionClearCanvas,
actionZoomIn,
actionZoomOut,
actionChangeProjectName,
actionChangeExportBackground,
actionLoadScene,
@ -127,17 +130,53 @@ const MOUSE_BUTTON = {
SECONDARY: 2,
};
let lastCanvasWidth = -1;
let lastCanvasHeight = -1;
let lastMouseUp: ((e: any) => void) | null = null;
export function viewportCoordsToSceneCoords(
{ clientX, clientY }: { clientX: number; clientY: number },
{ scrollX, scrollY }: { scrollX: number; scrollY: number },
{
scrollX,
scrollY,
zoom,
}: {
scrollX: number;
scrollY: number;
zoom: number;
},
canvas: HTMLCanvasElement | null,
) {
const x = clientX - scrollX;
const y = clientY - scrollY;
const zoomOrigin = getZoomOrigin(canvas);
const clientXWithZoom = zoomOrigin.x + (clientX - zoomOrigin.x) / zoom;
const clientYWithZoom = zoomOrigin.y + (clientY - zoomOrigin.y) / zoom;
const x = clientXWithZoom - scrollX;
const y = clientYWithZoom - scrollY;
return { x, y };
}
export function sceneCoordsToViewportCoords(
{ sceneX, sceneY }: { sceneX: number; sceneY: number },
{
scrollX,
scrollY,
zoom,
}: {
scrollX: number;
scrollY: number;
zoom: number;
},
canvas: HTMLCanvasElement | null,
) {
const zoomOrigin = getZoomOrigin(canvas);
const sceneXWithZoomAndScroll =
zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
const sceneYWithZoomAndScroll =
zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
const x = sceneXWithZoomAndScroll;
const y = sceneYWithZoomAndScroll;
return { x, y };
}
@ -320,6 +359,20 @@ const LayerUI = React.memo(
);
}
function renderZoomActions() {
return (
<Stack.Col gap={1}>
<Stack.Row gap={1} align="center">
{actionManager.renderAction("zoomIn")}
{actionManager.renderAction("zoomOut")}
<div style={{ marginLeft: 4 }}>
{(appState.zoom * 100).toFixed(0)}%
</div>
</Stack.Row>
</Stack.Col>
);
}
return (
<>
<FixedSideContainer side="top">
@ -370,6 +423,16 @@ const LayerUI = React.memo(
</section>
<div />
</div>
<div className="App-menu App-menu_bottom">
<Stack.Col gap={2}>
<section aria-labelledby="canvas-zoom-actions-title">
<h2 className="visually-hidden" id="canvas-zoom-actions-title">
{t("headings.canvasActions")}
</h2>
<Island padding={1}>{renderZoomActions()}</Island>
</section>
</Stack.Col>
</div>
</FixedSideContainer>
<footer role="contentinfo">
<HintViewer
@ -459,6 +522,8 @@ export class App extends React.Component<any, AppState> {
this.actionManager.registerAction(actionChangeViewBackgroundColor);
this.actionManager.registerAction(actionClearCanvas);
this.actionManager.registerAction(actionZoomIn);
this.actionManager.registerAction(actionZoomOut);
this.actionManager.registerAction(actionChangeProjectName);
this.actionManager.registerAction(actionChangeExportBackground);
@ -684,8 +749,6 @@ export class App extends React.Component<any, AppState> {
}
};
private removeWheelEventListener: (() => void) | undefined;
private copyToAppClipboard = () => {
copyToAppClipboard(elements);
};
@ -708,6 +771,7 @@ export class App extends React.Component<any, AppState> {
const { x, y } = viewportCoordsToSceneCoords(
{ clientX: cursorX, clientY: cursorY },
this.state,
this.canvas,
);
const element = newTextElement(
@ -759,8 +823,13 @@ export class App extends React.Component<any, AppState> {
};
public render() {
const canvasWidth = window.innerWidth;
const canvasHeight = window.innerHeight;
const canvasDOMWidth = window.innerWidth;
const canvasDOMHeight = window.innerHeight;
const canvasScale = window.devicePixelRatio;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
return (
<div className="container">
@ -777,46 +846,43 @@ export class App extends React.Component<any, AppState> {
<canvas
id="canvas"
style={{
width: canvasWidth,
height: canvasHeight,
width: canvasDOMWidth,
height: canvasDOMHeight,
}}
width={canvasWidth * window.devicePixelRatio}
height={canvasHeight * window.devicePixelRatio}
width={canvasWidth}
height={canvasHeight}
ref={canvas => {
if (this.canvas === null) {
// canvas is null when unmounting
if (canvas !== null) {
this.canvas = canvas;
this.rc = rough.canvas(this.canvas!);
}
if (this.removeWheelEventListener) {
this.removeWheelEventListener();
this.removeWheelEventListener = undefined;
}
if (canvas) {
canvas.addEventListener("wheel", this.handleWheel, {
this.rc = rough.canvas(this.canvas);
this.canvas.addEventListener("wheel", this.handleWheel, {
passive: false,
});
this.removeWheelEventListener = () =>
canvas.removeEventListener("wheel", this.handleWheel);
// Whenever React sets the width/height of the canvas element,
// the context loses the scale transform. We need to re-apply it
if (
canvasWidth !== lastCanvasWidth ||
canvasHeight !== lastCanvasHeight
) {
lastCanvasWidth = canvasWidth;
lastCanvasHeight = canvasHeight;
canvas
.getContext("2d")!
.scale(window.devicePixelRatio, window.devicePixelRatio);
}
this.canvas
.getContext("2d")
?.setTransform(canvasScale, 0, 0, canvasScale, 0, 0);
} else {
this.canvas?.removeEventListener("wheel", this.handleWheel);
}
}}
onContextMenu={e => {
e.preventDefault();
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
const element = getElementAtPosition(elements, x, y);
const element = getElementAtPosition(
elements,
x,
y,
this.state.zoom,
);
if (!element) {
ContextMenu.push({
options: [
@ -887,8 +953,8 @@ export class App extends React.Component<any, AppState> {
lastY = e.clientY;
this.setState({
scrollX: this.state.scrollX - deltaX,
scrollY: this.state.scrollY - deltaY,
scrollX: this.state.scrollX - deltaX / this.state.zoom,
scrollY: this.state.scrollY - deltaY / this.state.zoom,
});
};
const teardown = (lastMouseUp = () => {
@ -933,11 +999,14 @@ export class App extends React.Component<any, AppState> {
e.clientY / window.devicePixelRatio,
canvasWidth / window.devicePixelRatio,
canvasHeight / window.devicePixelRatio,
this.state.scrollX,
this.state.scrollY,
this.state,
);
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
const originX = x;
const originY = y;
@ -972,7 +1041,7 @@ export class App extends React.Component<any, AppState> {
const resizeElement = getElementWithResizeHandler(
elements,
{ x, y },
this.state,
this.state.zoom,
);
this.setState({
resizingElement: resizeElement ? resizeElement.element : null,
@ -985,7 +1054,12 @@ export class App extends React.Component<any, AppState> {
);
isResizingElements = true;
} else {
hitElement = getElementAtPosition(elements, x, y);
hitElement = getElementAtPosition(
elements,
x,
y,
this.state.zoom,
);
// clear selection if shift is not clicked
if (!hitElement?.isSelected && !e.shiftKey) {
elements = clearSelection(elements);
@ -1061,6 +1135,7 @@ export class App extends React.Component<any, AppState> {
strokeColor: this.state.currentItemStrokeColor,
opacity: this.state.currentItemOpacity,
font: this.state.currentItemFont,
zoom: this.state.zoom,
onSubmit: text => {
if (text) {
elements = [
@ -1211,7 +1286,9 @@ export class App extends React.Component<any, AppState> {
if (isOverHorizontalScrollBar) {
const x = e.clientX;
const dx = x - lastX;
this.setState({ scrollX: this.state.scrollX - dx });
this.setState({
scrollX: this.state.scrollX - dx / this.state.zoom,
});
lastX = x;
return;
}
@ -1219,7 +1296,9 @@ export class App extends React.Component<any, AppState> {
if (isOverVerticalScrollBar) {
const y = e.clientY;
const dy = y - lastY;
this.setState({ scrollY: this.state.scrollY - dy });
this.setState({
scrollY: this.state.scrollY - dy / this.state.zoom,
});
lastY = y;
return;
}
@ -1233,7 +1312,11 @@ export class App extends React.Component<any, AppState> {
(this.state.elementType === "arrow" ||
this.state.elementType === "line")
) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
return;
}
@ -1244,7 +1327,11 @@ export class App extends React.Component<any, AppState> {
const el = this.state.resizingElement;
const selectedElements = elements.filter(el => el.isSelected);
if (selectedElements.length === 1) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
const deltaX = x - lastX;
const deltaY = y - lastY;
const element = selectedElements[0];
@ -1467,7 +1554,11 @@ export class App extends React.Component<any, AppState> {
draggingOccurred = true;
const selectedElements = elements.filter(el => el.isSelected);
if (selectedElements.length) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
selectedElements.forEach(element => {
element.x += x - lastX;
@ -1487,7 +1578,11 @@ export class App extends React.Component<any, AppState> {
return;
}
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
let width = distance(originX, x);
let height = distance(originY, y);
@ -1581,7 +1676,11 @@ export class App extends React.Component<any, AppState> {
this.setState({});
}
if (!draggingOccurred && draggingElement && !multiElement) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
draggingElement.points.push([
x - draggingElement.x,
y - draggingElement.y,
@ -1696,9 +1795,18 @@ export class App extends React.Component<any, AppState> {
onDoubleClick={e => {
resetCursor();
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
const elementAtPosition = getElementAtPosition(elements, x, y);
const elementAtPosition = getElementAtPosition(
elements,
x,
y,
this.state.zoom,
);
const element =
elementAtPosition && isTextElement(elementAtPosition)
@ -1730,18 +1838,26 @@ export class App extends React.Component<any, AppState> {
);
this.setState({});
textX =
this.state.scrollX +
elementAtPosition.x +
elementAtPosition.width / 2;
textY =
this.state.scrollY +
elementAtPosition.y +
elementAtPosition.height / 2;
const centerElementX =
elementAtPosition.x + elementAtPosition.width / 2;
const centerElementY =
elementAtPosition.y + elementAtPosition.height / 2;
const {
x: centerElementXInViewport,
y: centerElementYInViewport,
} = sceneCoordsToViewportCoords(
{ sceneX: centerElementX, sceneY: centerElementY },
this.state,
this.canvas,
);
textX = centerElementXInViewport;
textY = centerElementYInViewport;
// x and y will change after calling newTextElement function
element.x = elementAtPosition.x + elementAtPosition.width / 2;
element.y = elementAtPosition.y + elementAtPosition.height / 2;
element.x = centerElementX;
element.y = centerElementY;
} else if (!e.altKey) {
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
x,
@ -1770,6 +1886,7 @@ export class App extends React.Component<any, AppState> {
strokeColor: element.strokeColor,
font: element.font,
opacity: this.state.currentItemOpacity,
zoom: this.state.zoom,
onSubmit: text => {
if (text) {
elements = [
@ -1796,7 +1913,11 @@ export class App extends React.Component<any, AppState> {
}
const hasDeselectedButton = Boolean(e.buttons);
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
if (this.state.multiElement) {
const { multiElement } = this.state;
const originX = multiElement.x;
@ -1823,7 +1944,7 @@ export class App extends React.Component<any, AppState> {
const resizeElement = getElementWithResizeHandler(
elements,
{ x, y },
this.state,
this.state.zoom,
);
if (resizeElement && resizeElement.resizeHandle) {
document.documentElement.style.cursor = getCursorForResizingElement(
@ -1832,7 +1953,12 @@ export class App extends React.Component<any, AppState> {
return;
}
}
const hitElement = getElementAtPosition(elements, x, y);
const hitElement = getElementAtPosition(
elements,
x,
y,
this.state.zoom,
);
document.documentElement.style.cursor = hitElement ? "move" : "";
}}
onDrop={e => {
@ -1858,8 +1984,8 @@ export class App extends React.Component<any, AppState> {
const { deltaX, deltaY } = e;
this.setState({
scrollX: this.state.scrollX - deltaX,
scrollY: this.state.scrollY - deltaY,
scrollX: this.state.scrollX - deltaX / this.state.zoom,
scrollY: this.state.scrollY - deltaY / this.state.zoom,
});
};
@ -1873,8 +1999,14 @@ export class App extends React.Component<any, AppState> {
const elementsCenterX = distance(minX, maxX) / 2;
const elementsCenterY = distance(minY, maxY) / 2;
const dx = cursorX - this.state.scrollX - elementsCenterX;
const dy = cursorY - this.state.scrollY - elementsCenterY;
const { x, y } = viewportCoordsToSceneCoords(
{ clientX: cursorX, clientY: cursorY },
this.state,
this.canvas,
);
const dx = x - elementsCenterX;
const dy = y - elementsCenterY;
elements = [
...elements,
@ -1933,6 +2065,7 @@ export class App extends React.Component<any, AppState> {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor,
zoom: this.state.zoom,
},
);
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;