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