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
@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { Action } from "./types";
|
import { Action } from "./types";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { trash } from "../components/icons";
|
import { trash, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
@ -53,3 +53,57 @@ export const actionClearCanvas: Action = {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
|
function getNormalizedZoom(zoom: number): number {
|
||||||
|
const normalizedZoom = parseFloat(zoom.toFixed(2));
|
||||||
|
const clampedZoom = Math.max(0.1, Math.min(normalizedZoom, 2));
|
||||||
|
return clampedZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionZoomIn: Action = {
|
||||||
|
name: "zoomIn",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={zoomIn}
|
||||||
|
title={t("buttons.zoomIn")}
|
||||||
|
aria-label={t("buttons.zoomIn")}
|
||||||
|
onClick={() => {
|
||||||
|
updateData(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionZoomOut: Action = {
|
||||||
|
name: "zoomOut",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={zoomOut}
|
||||||
|
title={t("buttons.zoomOut")}
|
||||||
|
aria-label={t("buttons.zoomOut")}
|
||||||
|
onClick={() => {
|
||||||
|
updateData(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
@ -21,6 +21,8 @@ export {
|
|||||||
export {
|
export {
|
||||||
actionChangeViewBackgroundColor,
|
actionChangeViewBackgroundColor,
|
||||||
actionClearCanvas,
|
actionClearCanvas,
|
||||||
|
actionZoomIn,
|
||||||
|
actionZoomOut,
|
||||||
} from "./actionCanvas";
|
} from "./actionCanvas";
|
||||||
|
|
||||||
export { actionFinalize } from "./actionFinalize";
|
export { actionFinalize } from "./actionFinalize";
|
||||||
|
@ -28,6 +28,7 @@ export function getDefaultAppState(): AppState {
|
|||||||
name: DEFAULT_PROJECT_NAME,
|
name: DEFAULT_PROJECT_NAME,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
|
zoom: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,3 +76,21 @@ export const exportFile = (
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const zoomIn = (
|
||||||
|
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const zoomOut = (
|
||||||
|
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { distanceBetweenPointAndSegment } from "../math";
|
import { distanceBetweenPointAndSegment } from "../math";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "./types";
|
import { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDiamondPoints,
|
getDiamondPoints,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
@ -17,10 +18,11 @@ export function hitTest(
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
|
zoom: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
// For shapes that are composed of lines, we only enable point-selection when the distance
|
// For shapes that are composed of lines, we only enable point-selection when the distance
|
||||||
// of the click is less than x pixels of any of the lines that the shape is composed of
|
// of the click is less than x pixels of any of the lines that the shape is composed of
|
||||||
const lineThreshold = 10;
|
const lineThreshold = 10 / zoom;
|
||||||
|
|
||||||
if (element.type === "ellipse") {
|
if (element.type === "ellipse") {
|
||||||
// https://stackoverflow.com/a/46007540/232122
|
// https://stackoverflow.com/a/46007540/232122
|
||||||
|
@ -1,98 +1,83 @@
|
|||||||
import { ExcalidrawElement } from "./types";
|
import { ExcalidrawElement } from "./types";
|
||||||
import { SceneScroll } from "../scene/types";
|
|
||||||
import { getLinearElementAbsoluteBounds } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
|
|
||||||
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
|
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
|
||||||
|
|
||||||
export function handlerRectangles(
|
export function handlerRectangles(element: ExcalidrawElement, zoom: number) {
|
||||||
element: ExcalidrawElement,
|
const handlerWidth = 8 / zoom;
|
||||||
{ scrollX, scrollY }: SceneScroll,
|
const handlerHeight = 8 / zoom;
|
||||||
) {
|
|
||||||
let elementX2 = 0;
|
|
||||||
let elementY2 = 0;
|
|
||||||
let elementX1 = Infinity;
|
|
||||||
let elementY1 = Infinity;
|
|
||||||
let marginX = -8;
|
|
||||||
let marginY = -8;
|
|
||||||
|
|
||||||
const minimumSize = 40;
|
const handlerMarginX = 8 / zoom;
|
||||||
if (element.type === "arrow" || element.type === "line") {
|
const handlerMarginY = 8 / zoom;
|
||||||
[
|
|
||||||
elementX1,
|
|
||||||
elementY1,
|
|
||||||
elementX2,
|
|
||||||
elementY2,
|
|
||||||
] = getLinearElementAbsoluteBounds(element);
|
|
||||||
} else {
|
|
||||||
elementX1 = element.x;
|
|
||||||
elementX2 = element.x + element.width;
|
|
||||||
elementY1 = element.y;
|
|
||||||
elementY2 = element.y + element.height;
|
|
||||||
|
|
||||||
marginX = element.width < 0 ? 8 : -8;
|
const [elementX1, elementY1, elementX2, elementY2] = getElementAbsoluteCoords(
|
||||||
marginY = element.height < 0 ? 8 : -8;
|
element,
|
||||||
}
|
);
|
||||||
|
|
||||||
const margin = 4;
|
const elementWidth = elementX2 - elementX1;
|
||||||
const handlers = {} as { [T in Sides]: number[] };
|
const elementHeight = elementY2 - elementY1;
|
||||||
|
|
||||||
if (Math.abs(elementX2 - elementX1) > minimumSize) {
|
const dashedLineMargin = 4 / zoom;
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
nw: [
|
||||||
|
elementX1 - dashedLineMargin - handlerMarginX,
|
||||||
|
elementY1 - dashedLineMargin - handlerMarginY,
|
||||||
|
handlerWidth,
|
||||||
|
handlerHeight,
|
||||||
|
],
|
||||||
|
ne: [
|
||||||
|
elementX2 + dashedLineMargin,
|
||||||
|
elementY1 - dashedLineMargin - handlerMarginY,
|
||||||
|
handlerWidth,
|
||||||
|
handlerHeight,
|
||||||
|
],
|
||||||
|
sw: [
|
||||||
|
elementX1 - dashedLineMargin - handlerMarginX,
|
||||||
|
elementY2 + dashedLineMargin,
|
||||||
|
handlerWidth,
|
||||||
|
handlerHeight,
|
||||||
|
],
|
||||||
|
se: [
|
||||||
|
elementX2 + dashedLineMargin,
|
||||||
|
elementY2 + dashedLineMargin,
|
||||||
|
handlerWidth,
|
||||||
|
handlerHeight,
|
||||||
|
],
|
||||||
|
} as { [T in Sides]: number[] };
|
||||||
|
|
||||||
|
// We only want to show height handlers (all cardinal directions) above a certain size
|
||||||
|
const minimumSizeForEightHandlers = 40 / zoom;
|
||||||
|
if (Math.abs(elementWidth) > minimumSizeForEightHandlers) {
|
||||||
handlers["n"] = [
|
handlers["n"] = [
|
||||||
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
|
elementX1 + elementWidth / 2,
|
||||||
elementY1 - margin + scrollY + marginY,
|
elementY1 - dashedLineMargin - handlerMarginY,
|
||||||
8,
|
handlerWidth,
|
||||||
8,
|
handlerHeight,
|
||||||
];
|
];
|
||||||
|
|
||||||
handlers["s"] = [
|
handlers["s"] = [
|
||||||
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
|
elementX1 + elementWidth / 2,
|
||||||
elementY2 - margin + scrollY - marginY,
|
elementY2 + dashedLineMargin,
|
||||||
8,
|
handlerWidth,
|
||||||
8,
|
handlerHeight,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (Math.abs(elementHeight) > minimumSizeForEightHandlers) {
|
||||||
if (Math.abs(elementY2 - elementY1) > minimumSize) {
|
|
||||||
handlers["w"] = [
|
handlers["w"] = [
|
||||||
elementX1 - margin + scrollX + marginX,
|
elementX1 - dashedLineMargin - handlerMarginX,
|
||||||
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
|
elementY1 + elementHeight / 2,
|
||||||
8,
|
handlerWidth,
|
||||||
8,
|
handlerHeight,
|
||||||
];
|
];
|
||||||
|
|
||||||
handlers["e"] = [
|
handlers["e"] = [
|
||||||
elementX2 - margin + scrollX - marginX,
|
elementX2 + dashedLineMargin,
|
||||||
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
|
elementY1 + elementHeight / 2,
|
||||||
8,
|
handlerWidth,
|
||||||
8,
|
handlerHeight,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
handlers["nw"] = [
|
|
||||||
elementX1 - margin + scrollX + marginX,
|
|
||||||
elementY1 - margin + scrollY + marginY,
|
|
||||||
8,
|
|
||||||
8,
|
|
||||||
]; // nw
|
|
||||||
handlers["ne"] = [
|
|
||||||
elementX2 - margin + scrollX - marginX,
|
|
||||||
elementY1 - margin + scrollY + marginY,
|
|
||||||
8,
|
|
||||||
8,
|
|
||||||
]; // ne
|
|
||||||
handlers["sw"] = [
|
|
||||||
elementX1 - margin + scrollX + marginX,
|
|
||||||
elementY2 - margin + scrollY - marginY,
|
|
||||||
8,
|
|
||||||
8,
|
|
||||||
]; // sw
|
|
||||||
handlers["se"] = [
|
|
||||||
elementX2 - margin + scrollX - marginX,
|
|
||||||
elementY2 - margin + scrollY - marginY,
|
|
||||||
8,
|
|
||||||
8,
|
|
||||||
]; // se
|
|
||||||
|
|
||||||
if (element.type === "arrow" || element.type === "line") {
|
if (element.type === "arrow" || element.type === "line") {
|
||||||
if (element.points.length === 2) {
|
if (element.points.length === 2) {
|
||||||
// only check the last point because starting point is always (0,0)
|
// only check the last point because starting point is always (0,0)
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { ExcalidrawElement } from "./types";
|
import { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
import { handlerRectangles } from "./handlerRectangles";
|
import { handlerRectangles } from "./handlerRectangles";
|
||||||
import { SceneScroll } from "../scene/types";
|
|
||||||
|
|
||||||
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
||||||
|
|
||||||
@ -9,13 +8,13 @@ export function resizeTest(
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
{ scrollX, scrollY }: SceneScroll,
|
zoom: number,
|
||||||
): HandlerRectanglesRet | false {
|
): HandlerRectanglesRet | false {
|
||||||
if (!element.isSelected || element.type === "text") {
|
if (!element.isSelected || element.type === "text") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = handlerRectangles(element, { scrollX, scrollY });
|
const handlers = handlerRectangles(element, zoom);
|
||||||
|
|
||||||
const filter = Object.keys(handlers).filter(key => {
|
const filter = Object.keys(handlers).filter(key => {
|
||||||
const handler = handlers[key as HandlerRectanglesRet]!;
|
const handler = handlers[key as HandlerRectanglesRet]!;
|
||||||
@ -24,10 +23,10 @@ export function resizeTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
x + scrollX >= handler[0] &&
|
x >= handler[0] &&
|
||||||
x + scrollX <= handler[0] + handler[2] &&
|
x <= handler[0] + handler[2] &&
|
||||||
y + scrollY >= handler[1] &&
|
y >= handler[1] &&
|
||||||
y + scrollY <= handler[1] + handler[3]
|
y <= handler[1] + handler[3]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -41,16 +40,13 @@ export function resizeTest(
|
|||||||
export function getElementWithResizeHandler(
|
export function getElementWithResizeHandler(
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
{ x, y }: { x: number; y: number },
|
{ x, y }: { x: number; y: number },
|
||||||
{ scrollX, scrollY }: SceneScroll,
|
zoom: number,
|
||||||
) {
|
) {
|
||||||
return elements.reduce((result, element) => {
|
return elements.reduce((result, element) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const resizeHandle = resizeTest(element, x, y, {
|
const resizeHandle = resizeTest(element, x, y, zoom);
|
||||||
scrollX,
|
|
||||||
scrollY,
|
|
||||||
});
|
|
||||||
return resizeHandle ? { element, resizeHandle } : null;
|
return resizeHandle ? { element, resizeHandle } : null;
|
||||||
}, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
|
}, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ type TextWysiwygParams = {
|
|||||||
strokeColor: string;
|
strokeColor: string;
|
||||||
font: string;
|
font: string;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
|
zoom: number;
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
@ -25,6 +26,7 @@ export function textWysiwyg({
|
|||||||
strokeColor,
|
strokeColor,
|
||||||
font,
|
font,
|
||||||
opacity,
|
opacity,
|
||||||
|
zoom,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: TextWysiwygParams) {
|
}: TextWysiwygParams) {
|
||||||
@ -43,7 +45,7 @@ export function textWysiwyg({
|
|||||||
opacity: opacity / 100,
|
opacity: opacity / 100,
|
||||||
top: `${y}px`,
|
top: `${y}px`,
|
||||||
left: `${x}px`,
|
left: `${x}px`,
|
||||||
transform: "translate(-50%, -50%)",
|
transform: `translate(-50%, -50%) scale(${zoom})`,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
font: font,
|
font: font,
|
||||||
|
277
src/index.tsx
277
src/index.tsx
@ -34,6 +34,7 @@ import {
|
|||||||
loadScene,
|
loadScene,
|
||||||
calculateScrollCenter,
|
calculateScrollCenter,
|
||||||
loadFromBlob,
|
loadFromBlob,
|
||||||
|
getZoomOrigin,
|
||||||
} from "./scene";
|
} from "./scene";
|
||||||
|
|
||||||
import { renderScene } from "./renderer";
|
import { renderScene } from "./renderer";
|
||||||
@ -77,6 +78,8 @@ import {
|
|||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeViewBackgroundColor,
|
actionChangeViewBackgroundColor,
|
||||||
actionClearCanvas,
|
actionClearCanvas,
|
||||||
|
actionZoomIn,
|
||||||
|
actionZoomOut,
|
||||||
actionChangeProjectName,
|
actionChangeProjectName,
|
||||||
actionChangeExportBackground,
|
actionChangeExportBackground,
|
||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
@ -127,17 +130,53 @@ const MOUSE_BUTTON = {
|
|||||||
SECONDARY: 2,
|
SECONDARY: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastCanvasWidth = -1;
|
|
||||||
let lastCanvasHeight = -1;
|
|
||||||
|
|
||||||
let lastMouseUp: ((e: any) => void) | null = null;
|
let lastMouseUp: ((e: any) => void) | null = null;
|
||||||
|
|
||||||
export function viewportCoordsToSceneCoords(
|
export function viewportCoordsToSceneCoords(
|
||||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
{ 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 zoomOrigin = getZoomOrigin(canvas);
|
||||||
const y = clientY - scrollY;
|
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 };
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
@ -370,6 +423,16 @@ const LayerUI = React.memo(
|
|||||||
</section>
|
</section>
|
||||||
<div />
|
<div />
|
||||||
</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>
|
</FixedSideContainer>
|
||||||
<footer role="contentinfo">
|
<footer role="contentinfo">
|
||||||
<HintViewer
|
<HintViewer
|
||||||
@ -459,6 +522,8 @@ export class App extends React.Component<any, AppState> {
|
|||||||
|
|
||||||
this.actionManager.registerAction(actionChangeViewBackgroundColor);
|
this.actionManager.registerAction(actionChangeViewBackgroundColor);
|
||||||
this.actionManager.registerAction(actionClearCanvas);
|
this.actionManager.registerAction(actionClearCanvas);
|
||||||
|
this.actionManager.registerAction(actionZoomIn);
|
||||||
|
this.actionManager.registerAction(actionZoomOut);
|
||||||
|
|
||||||
this.actionManager.registerAction(actionChangeProjectName);
|
this.actionManager.registerAction(actionChangeProjectName);
|
||||||
this.actionManager.registerAction(actionChangeExportBackground);
|
this.actionManager.registerAction(actionChangeExportBackground);
|
||||||
@ -684,8 +749,6 @@ export class App extends React.Component<any, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private removeWheelEventListener: (() => void) | undefined;
|
|
||||||
|
|
||||||
private copyToAppClipboard = () => {
|
private copyToAppClipboard = () => {
|
||||||
copyToAppClipboard(elements);
|
copyToAppClipboard(elements);
|
||||||
};
|
};
|
||||||
@ -708,6 +771,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
const { x, y } = viewportCoordsToSceneCoords(
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
{ clientX: cursorX, clientY: cursorY },
|
{ clientX: cursorX, clientY: cursorY },
|
||||||
this.state,
|
this.state,
|
||||||
|
this.canvas,
|
||||||
);
|
);
|
||||||
|
|
||||||
const element = newTextElement(
|
const element = newTextElement(
|
||||||
@ -759,8 +823,13 @@ export class App extends React.Component<any, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const canvasWidth = window.innerWidth;
|
const canvasDOMWidth = window.innerWidth;
|
||||||
const canvasHeight = window.innerHeight;
|
const canvasDOMHeight = window.innerHeight;
|
||||||
|
|
||||||
|
const canvasScale = window.devicePixelRatio;
|
||||||
|
|
||||||
|
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||||
|
const canvasHeight = canvasDOMHeight * canvasScale;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -777,46 +846,43 @@ export class App extends React.Component<any, AppState> {
|
|||||||
<canvas
|
<canvas
|
||||||
id="canvas"
|
id="canvas"
|
||||||
style={{
|
style={{
|
||||||
width: canvasWidth,
|
width: canvasDOMWidth,
|
||||||
height: canvasHeight,
|
height: canvasDOMHeight,
|
||||||
}}
|
}}
|
||||||
width={canvasWidth * window.devicePixelRatio}
|
width={canvasWidth}
|
||||||
height={canvasHeight * window.devicePixelRatio}
|
height={canvasHeight}
|
||||||
ref={canvas => {
|
ref={canvas => {
|
||||||
if (this.canvas === null) {
|
// canvas is null when unmounting
|
||||||
|
if (canvas !== null) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.rc = rough.canvas(this.canvas!);
|
this.rc = rough.canvas(this.canvas);
|
||||||
}
|
|
||||||
if (this.removeWheelEventListener) {
|
this.canvas.addEventListener("wheel", this.handleWheel, {
|
||||||
this.removeWheelEventListener();
|
|
||||||
this.removeWheelEventListener = undefined;
|
|
||||||
}
|
|
||||||
if (canvas) {
|
|
||||||
canvas.addEventListener("wheel", this.handleWheel, {
|
|
||||||
passive: false,
|
passive: false,
|
||||||
});
|
});
|
||||||
this.removeWheelEventListener = () =>
|
|
||||||
canvas.removeEventListener("wheel", this.handleWheel);
|
this.canvas
|
||||||
// Whenever React sets the width/height of the canvas element,
|
.getContext("2d")
|
||||||
// the context loses the scale transform. We need to re-apply it
|
?.setTransform(canvasScale, 0, 0, canvasScale, 0, 0);
|
||||||
if (
|
} else {
|
||||||
canvasWidth !== lastCanvasWidth ||
|
this.canvas?.removeEventListener("wheel", this.handleWheel);
|
||||||
canvasHeight !== lastCanvasHeight
|
|
||||||
) {
|
|
||||||
lastCanvasWidth = canvasWidth;
|
|
||||||
lastCanvasHeight = canvasHeight;
|
|
||||||
canvas
|
|
||||||
.getContext("2d")!
|
|
||||||
.scale(window.devicePixelRatio, window.devicePixelRatio);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
e.preventDefault();
|
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) {
|
if (!element) {
|
||||||
ContextMenu.push({
|
ContextMenu.push({
|
||||||
options: [
|
options: [
|
||||||
@ -887,8 +953,8 @@ export class App extends React.Component<any, AppState> {
|
|||||||
lastY = e.clientY;
|
lastY = e.clientY;
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
scrollX: this.state.scrollX - deltaX,
|
scrollX: this.state.scrollX - deltaX / this.state.zoom,
|
||||||
scrollY: this.state.scrollY - deltaY,
|
scrollY: this.state.scrollY - deltaY / this.state.zoom,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const teardown = (lastMouseUp = () => {
|
const teardown = (lastMouseUp = () => {
|
||||||
@ -933,11 +999,14 @@ export class App extends React.Component<any, AppState> {
|
|||||||
e.clientY / window.devicePixelRatio,
|
e.clientY / window.devicePixelRatio,
|
||||||
canvasWidth / window.devicePixelRatio,
|
canvasWidth / window.devicePixelRatio,
|
||||||
canvasHeight / window.devicePixelRatio,
|
canvasHeight / window.devicePixelRatio,
|
||||||
this.state.scrollX,
|
this.state,
|
||||||
this.state.scrollY,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
|
e,
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
);
|
||||||
|
|
||||||
const originX = x;
|
const originX = x;
|
||||||
const originY = y;
|
const originY = y;
|
||||||
@ -972,7 +1041,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
const resizeElement = getElementWithResizeHandler(
|
const resizeElement = getElementWithResizeHandler(
|
||||||
elements,
|
elements,
|
||||||
{ x, y },
|
{ x, y },
|
||||||
this.state,
|
this.state.zoom,
|
||||||
);
|
);
|
||||||
this.setState({
|
this.setState({
|
||||||
resizingElement: resizeElement ? resizeElement.element : null,
|
resizingElement: resizeElement ? resizeElement.element : null,
|
||||||
@ -985,7 +1054,12 @@ export class App extends React.Component<any, AppState> {
|
|||||||
);
|
);
|
||||||
isResizingElements = true;
|
isResizingElements = true;
|
||||||
} else {
|
} else {
|
||||||
hitElement = getElementAtPosition(elements, x, y);
|
hitElement = getElementAtPosition(
|
||||||
|
elements,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
this.state.zoom,
|
||||||
|
);
|
||||||
// clear selection if shift is not clicked
|
// clear selection if shift is not clicked
|
||||||
if (!hitElement?.isSelected && !e.shiftKey) {
|
if (!hitElement?.isSelected && !e.shiftKey) {
|
||||||
elements = clearSelection(elements);
|
elements = clearSelection(elements);
|
||||||
@ -1061,6 +1135,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
strokeColor: this.state.currentItemStrokeColor,
|
strokeColor: this.state.currentItemStrokeColor,
|
||||||
opacity: this.state.currentItemOpacity,
|
opacity: this.state.currentItemOpacity,
|
||||||
font: this.state.currentItemFont,
|
font: this.state.currentItemFont,
|
||||||
|
zoom: this.state.zoom,
|
||||||
onSubmit: text => {
|
onSubmit: text => {
|
||||||
if (text) {
|
if (text) {
|
||||||
elements = [
|
elements = [
|
||||||
@ -1211,7 +1286,9 @@ export class App extends React.Component<any, AppState> {
|
|||||||
if (isOverHorizontalScrollBar) {
|
if (isOverHorizontalScrollBar) {
|
||||||
const x = e.clientX;
|
const x = e.clientX;
|
||||||
const dx = x - lastX;
|
const dx = x - lastX;
|
||||||
this.setState({ scrollX: this.state.scrollX - dx });
|
this.setState({
|
||||||
|
scrollX: this.state.scrollX - dx / this.state.zoom,
|
||||||
|
});
|
||||||
lastX = x;
|
lastX = x;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1219,7 +1296,9 @@ export class App extends React.Component<any, AppState> {
|
|||||||
if (isOverVerticalScrollBar) {
|
if (isOverVerticalScrollBar) {
|
||||||
const y = e.clientY;
|
const y = e.clientY;
|
||||||
const dy = y - lastY;
|
const dy = y - lastY;
|
||||||
this.setState({ scrollY: this.state.scrollY - dy });
|
this.setState({
|
||||||
|
scrollY: this.state.scrollY - dy / this.state.zoom,
|
||||||
|
});
|
||||||
lastY = y;
|
lastY = y;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1233,7 +1312,11 @@ export class App extends React.Component<any, AppState> {
|
|||||||
(this.state.elementType === "arrow" ||
|
(this.state.elementType === "arrow" ||
|
||||||
this.state.elementType === "line")
|
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) {
|
if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1244,7 +1327,11 @@ export class App extends React.Component<any, AppState> {
|
|||||||
const el = this.state.resizingElement;
|
const el = this.state.resizingElement;
|
||||||
const selectedElements = elements.filter(el => el.isSelected);
|
const selectedElements = elements.filter(el => el.isSelected);
|
||||||
if (selectedElements.length === 1) {
|
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 deltaX = x - lastX;
|
||||||
const deltaY = y - lastY;
|
const deltaY = y - lastY;
|
||||||
const element = selectedElements[0];
|
const element = selectedElements[0];
|
||||||
@ -1467,7 +1554,11 @@ export class App extends React.Component<any, AppState> {
|
|||||||
draggingOccurred = true;
|
draggingOccurred = true;
|
||||||
const selectedElements = elements.filter(el => el.isSelected);
|
const selectedElements = elements.filter(el => el.isSelected);
|
||||||
if (selectedElements.length) {
|
if (selectedElements.length) {
|
||||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
|
e,
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
);
|
||||||
|
|
||||||
selectedElements.forEach(element => {
|
selectedElements.forEach(element => {
|
||||||
element.x += x - lastX;
|
element.x += x - lastX;
|
||||||
@ -1487,7 +1578,11 @@ export class App extends React.Component<any, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
|
e,
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
);
|
||||||
|
|
||||||
let width = distance(originX, x);
|
let width = distance(originX, x);
|
||||||
let height = distance(originY, y);
|
let height = distance(originY, y);
|
||||||
@ -1581,7 +1676,11 @@ export class App extends React.Component<any, AppState> {
|
|||||||
this.setState({});
|
this.setState({});
|
||||||
}
|
}
|
||||||
if (!draggingOccurred && draggingElement && !multiElement) {
|
if (!draggingOccurred && draggingElement && !multiElement) {
|
||||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
|
e,
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
);
|
||||||
draggingElement.points.push([
|
draggingElement.points.push([
|
||||||
x - draggingElement.x,
|
x - draggingElement.x,
|
||||||
y - draggingElement.y,
|
y - draggingElement.y,
|
||||||
@ -1696,9 +1795,18 @@ export class App extends React.Component<any, AppState> {
|
|||||||
onDoubleClick={e => {
|
onDoubleClick={e => {
|
||||||
resetCursor();
|
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 =
|
const element =
|
||||||
elementAtPosition && isTextElement(elementAtPosition)
|
elementAtPosition && isTextElement(elementAtPosition)
|
||||||
@ -1730,18 +1838,26 @@ export class App extends React.Component<any, AppState> {
|
|||||||
);
|
);
|
||||||
this.setState({});
|
this.setState({});
|
||||||
|
|
||||||
textX =
|
const centerElementX =
|
||||||
this.state.scrollX +
|
elementAtPosition.x + elementAtPosition.width / 2;
|
||||||
elementAtPosition.x +
|
const centerElementY =
|
||||||
elementAtPosition.width / 2;
|
elementAtPosition.y + elementAtPosition.height / 2;
|
||||||
textY =
|
|
||||||
this.state.scrollY +
|
const {
|
||||||
elementAtPosition.y +
|
x: centerElementXInViewport,
|
||||||
elementAtPosition.height / 2;
|
y: centerElementYInViewport,
|
||||||
|
} = sceneCoordsToViewportCoords(
|
||||||
|
{ sceneX: centerElementX, sceneY: centerElementY },
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
);
|
||||||
|
|
||||||
|
textX = centerElementXInViewport;
|
||||||
|
textY = centerElementYInViewport;
|
||||||
|
|
||||||
// x and y will change after calling newTextElement function
|
// x and y will change after calling newTextElement function
|
||||||
element.x = elementAtPosition.x + elementAtPosition.width / 2;
|
element.x = centerElementX;
|
||||||
element.y = elementAtPosition.y + elementAtPosition.height / 2;
|
element.y = centerElementY;
|
||||||
} else if (!e.altKey) {
|
} else if (!e.altKey) {
|
||||||
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
||||||
x,
|
x,
|
||||||
@ -1770,6 +1886,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
strokeColor: element.strokeColor,
|
strokeColor: element.strokeColor,
|
||||||
font: element.font,
|
font: element.font,
|
||||||
opacity: this.state.currentItemOpacity,
|
opacity: this.state.currentItemOpacity,
|
||||||
|
zoom: this.state.zoom,
|
||||||
onSubmit: text => {
|
onSubmit: text => {
|
||||||
if (text) {
|
if (text) {
|
||||||
elements = [
|
elements = [
|
||||||
@ -1796,7 +1913,11 @@ export class App extends React.Component<any, AppState> {
|
|||||||
}
|
}
|
||||||
const hasDeselectedButton = Boolean(e.buttons);
|
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) {
|
if (this.state.multiElement) {
|
||||||
const { multiElement } = this.state;
|
const { multiElement } = this.state;
|
||||||
const originX = multiElement.x;
|
const originX = multiElement.x;
|
||||||
@ -1823,7 +1944,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
const resizeElement = getElementWithResizeHandler(
|
const resizeElement = getElementWithResizeHandler(
|
||||||
elements,
|
elements,
|
||||||
{ x, y },
|
{ x, y },
|
||||||
this.state,
|
this.state.zoom,
|
||||||
);
|
);
|
||||||
if (resizeElement && resizeElement.resizeHandle) {
|
if (resizeElement && resizeElement.resizeHandle) {
|
||||||
document.documentElement.style.cursor = getCursorForResizingElement(
|
document.documentElement.style.cursor = getCursorForResizingElement(
|
||||||
@ -1832,7 +1953,12 @@ export class App extends React.Component<any, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hitElement = getElementAtPosition(elements, x, y);
|
const hitElement = getElementAtPosition(
|
||||||
|
elements,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
this.state.zoom,
|
||||||
|
);
|
||||||
document.documentElement.style.cursor = hitElement ? "move" : "";
|
document.documentElement.style.cursor = hitElement ? "move" : "";
|
||||||
}}
|
}}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
@ -1858,8 +1984,8 @@ export class App extends React.Component<any, AppState> {
|
|||||||
const { deltaX, deltaY } = e;
|
const { deltaX, deltaY } = e;
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
scrollX: this.state.scrollX - deltaX,
|
scrollX: this.state.scrollX - deltaX / this.state.zoom,
|
||||||
scrollY: this.state.scrollY - deltaY,
|
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 elementsCenterX = distance(minX, maxX) / 2;
|
||||||
const elementsCenterY = distance(minY, maxY) / 2;
|
const elementsCenterY = distance(minY, maxY) / 2;
|
||||||
|
|
||||||
const dx = cursorX - this.state.scrollX - elementsCenterX;
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
const dy = cursorY - this.state.scrollY - elementsCenterY;
|
{ clientX: cursorX, clientY: cursorY },
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dx = x - elementsCenterX;
|
||||||
|
const dy = y - elementsCenterY;
|
||||||
|
|
||||||
elements = [
|
elements = [
|
||||||
...elements,
|
...elements,
|
||||||
@ -1933,6 +2065,7 @@ export class App extends React.Component<any, AppState> {
|
|||||||
scrollX: this.state.scrollX,
|
scrollX: this.state.scrollX,
|
||||||
scrollY: this.state.scrollY,
|
scrollY: this.state.scrollY,
|
||||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||||
|
zoom: this.state.zoom,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
|
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
|
||||||
|
@ -53,7 +53,9 @@
|
|||||||
"getShareableLink": "Get shareable link",
|
"getShareableLink": "Get shareable link",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"selectLanguage": "Select Language",
|
"selectLanguage": "Select Language",
|
||||||
"scrollBackToContent": "Scroll back to content"
|
"scrollBackToContent": "Scroll back to content",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"zoomOut": "Zoom out"
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"clearReset": "This will clear the whole canvas. Are you sure?",
|
"clearReset": "This will clear the whole canvas. Are you sure?",
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
SCROLLBAR_COLOR,
|
SCROLLBAR_COLOR,
|
||||||
SCROLLBAR_WIDTH,
|
SCROLLBAR_WIDTH,
|
||||||
} from "../scene/scrollbars";
|
} from "../scene/scrollbars";
|
||||||
|
import { getZoomTranslation } from "../scene/zoom";
|
||||||
|
|
||||||
import { renderElement, renderElementToSvg } from "./renderElement";
|
import { renderElement, renderElementToSvg } from "./renderElement";
|
||||||
|
|
||||||
@ -32,116 +33,164 @@ export function renderScene(
|
|||||||
renderScrollbars?: boolean;
|
renderScrollbars?: boolean;
|
||||||
renderSelection?: boolean;
|
renderSelection?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
): boolean {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return false;
|
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 = {
|
||||||
...sceneState,
|
...sceneState,
|
||||||
scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX,
|
scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX,
|
||||||
scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY,
|
scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY,
|
||||||
};
|
};
|
||||||
|
|
||||||
let atLeastOneVisibleElement = false;
|
const context = canvas.getContext("2d")!;
|
||||||
elements.forEach(element => {
|
|
||||||
if (
|
// Get initial scale transform as reference for later usage
|
||||||
!isVisibleElement(
|
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,
|
element,
|
||||||
sceneState.scrollX,
|
normalizedCanvasWidth,
|
||||||
sceneState.scrollY,
|
normalizedCanvasHeight,
|
||||||
// If canvas is scaled for high pixelDeviceRatio width and height
|
sceneState,
|
||||||
// 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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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);
|
renderElement(element, rc, context);
|
||||||
context.translate(
|
context.restore();
|
||||||
-element.x - sceneState.scrollX,
|
|
||||||
-element.y - sceneState.scrollY,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
// Pain selection element
|
||||||
if (selectionElement) {
|
if (selectionElement) {
|
||||||
context.translate(
|
context.save();
|
||||||
selectionElement.x + sceneState.scrollX,
|
scaleContextToZoom();
|
||||||
selectionElement.y + sceneState.scrollY,
|
translateContextToZoom();
|
||||||
);
|
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||||
|
context.translate(selectionElement.x, selectionElement.y);
|
||||||
renderElement(selectionElement, rc, context);
|
renderElement(selectionElement, rc, context);
|
||||||
context.translate(
|
context.restore();
|
||||||
-selectionElement.x - sceneState.scrollX,
|
|
||||||
-selectionElement.y - sceneState.scrollY,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pain selected elements
|
||||||
if (renderSelection) {
|
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 => {
|
selectedElements.forEach(element => {
|
||||||
const margin = 4;
|
|
||||||
|
|
||||||
const [
|
const [
|
||||||
elementX1,
|
elementX1,
|
||||||
elementY1,
|
elementY1,
|
||||||
elementX2,
|
elementX2,
|
||||||
elementY2,
|
elementY2,
|
||||||
] = getElementAbsoluteCoords(element);
|
] = 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") {
|
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)
|
Object.values(handlers)
|
||||||
.filter(handler => handler !== undefined)
|
.filter(handler => handler !== undefined)
|
||||||
.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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paint scrollbars
|
||||||
if (renderScrollbars) {
|
if (renderScrollbars) {
|
||||||
const scrollBars = getScrollBars(
|
const scrollBars = getScrollBars(
|
||||||
elements,
|
elements,
|
||||||
context.canvas.width / window.devicePixelRatio,
|
normalizedCanvasWidth,
|
||||||
context.canvas.height / window.devicePixelRatio,
|
normalizedCanvasHeight,
|
||||||
sceneState.scrollX,
|
sceneState,
|
||||||
sceneState.scrollY,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const strokeStyle = context.strokeStyle;
|
context.save();
|
||||||
context.fillStyle = SCROLLBAR_COLOR;
|
context.fillStyle = SCROLLBAR_COLOR;
|
||||||
context.strokeStyle = "rgba(255,255,255,0.8)";
|
context.strokeStyle = "rgba(255,255,255,0.8)";
|
||||||
[scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => {
|
[scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => {
|
||||||
@ -156,33 +205,40 @@ export function renderScene(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
context.strokeStyle = strokeStyle;
|
context.restore();
|
||||||
context.fillStyle = fillStyle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return atLeastOneVisibleElement;
|
return visibleElements.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisibleElement(
|
function isVisibleElement(
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
scrollX: number,
|
viewportWidth: number,
|
||||||
scrollY: number,
|
viewportHeight: number,
|
||||||
canvasWidth: number,
|
{
|
||||||
canvasHeight: number,
|
scrollX,
|
||||||
|
scrollY,
|
||||||
|
zoom,
|
||||||
|
}: {
|
||||||
|
scrollX: number;
|
||||||
|
scrollY: number;
|
||||||
|
zoom: number;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
if (element.type !== "arrow") {
|
|
||||||
x1 += scrollX;
|
// Apply zoom
|
||||||
y1 += scrollY;
|
const viewportWidthWithZoom = viewportWidth / zoom;
|
||||||
x2 += scrollX;
|
const viewportHeightWithZoom = viewportHeight / zoom;
|
||||||
y2 += scrollY;
|
|
||||||
return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
|
const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
|
||||||
}
|
const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
x2 + scrollX >= 0 &&
|
x2 + scrollX - viewportWidthDiff / 2 >= 0 &&
|
||||||
x1 + scrollX <= canvasWidth &&
|
x1 + scrollX - viewportWidthDiff / 2 <= viewportWidthWithZoom &&
|
||||||
y2 + scrollY >= 0 &&
|
y2 + scrollY - viewportHeightDiff / 2 >= 0 &&
|
||||||
y1 + scrollY <= canvasHeight
|
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;
|
||||||
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { hitTest } from "../element/collision";
|
|
||||||
import { getElementAbsoluteCoords } from "../element";
|
import { getElementAbsoluteCoords, hitTest } from "../element";
|
||||||
|
|
||||||
export const hasBackground = (type: string) =>
|
export const hasBackground = (type: string) =>
|
||||||
type === "rectangle" || type === "ellipse" || type === "diamond";
|
type === "rectangle" || type === "ellipse" || type === "diamond";
|
||||||
@ -18,11 +18,12 @@ export function getElementAtPosition(
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
|
zoom: number,
|
||||||
) {
|
) {
|
||||||
let hitElement = null;
|
let hitElement = null;
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||||
for (let i = elements.length - 1; i >= 0; --i) {
|
for (let i = elements.length - 1; i >= 0; --i) {
|
||||||
if (hitTest(elements[i], x, y)) {
|
if (hitTest(elements[i], x, y, zoom)) {
|
||||||
hitElement = elements[i];
|
hitElement = elements[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -44,6 +44,7 @@ export function exportToCanvas(
|
|||||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||||
scrollX: 0,
|
scrollX: 0,
|
||||||
scrollY: 0,
|
scrollY: 0,
|
||||||
|
zoom: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offsetX: -minX + exportPadding,
|
offsetX: -minX + exportPadding,
|
||||||
|
@ -27,3 +27,4 @@ export {
|
|||||||
hasText,
|
hasText,
|
||||||
} from "./comparisons";
|
} from "./comparisons";
|
||||||
export { createScene } from "./createScene";
|
export { createScene } from "./createScene";
|
||||||
|
export { getZoomOrigin, getZoomTranslation } from "./zoom";
|
||||||
|
@ -9,8 +9,15 @@ export function getScrollBars(
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
viewportWidth: number,
|
viewportWidth: number,
|
||||||
viewportHeight: number,
|
viewportHeight: number,
|
||||||
scrollX: number,
|
{
|
||||||
scrollY: number,
|
scrollX,
|
||||||
|
scrollY,
|
||||||
|
zoom,
|
||||||
|
}: {
|
||||||
|
scrollX: number;
|
||||||
|
scrollY: number;
|
||||||
|
zoom: number;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
// This is the bounding box of all the elements
|
// This is the bounding box of all the elements
|
||||||
const [
|
const [
|
||||||
@ -20,11 +27,18 @@ export function getScrollBars(
|
|||||||
elementsMaxY,
|
elementsMaxY,
|
||||||
] = getCommonBounds(elements);
|
] = getCommonBounds(elements);
|
||||||
|
|
||||||
|
// Apply zoom
|
||||||
|
const viewportWidthWithZoom = viewportWidth / zoom;
|
||||||
|
const viewportHeightWithZoom = viewportHeight / zoom;
|
||||||
|
|
||||||
|
const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
|
||||||
|
const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
|
||||||
|
|
||||||
// The viewport is the rectangle currently visible for the user
|
// The viewport is the rectangle currently visible for the user
|
||||||
const viewportMinX = -scrollX;
|
const viewportMinX = -scrollX + viewportWidthDiff / 2;
|
||||||
const viewportMinY = -scrollY;
|
const viewportMinY = -scrollY + viewportHeightDiff / 2;
|
||||||
const viewportMaxX = -scrollX + viewportWidth;
|
const viewportMaxX = viewportMinX + viewportWidthWithZoom;
|
||||||
const viewportMaxY = -scrollY + viewportHeight;
|
const viewportMaxY = viewportMinY + viewportHeightWithZoom;
|
||||||
|
|
||||||
// The scene is the bounding box of both the elements and viewport
|
// The scene is the bounding box of both the elements and viewport
|
||||||
const sceneMinX = Math.min(elementsMinX, viewportMinX);
|
const sceneMinX = Math.min(elementsMinX, viewportMinX);
|
||||||
@ -74,16 +88,21 @@ export function isOverScrollBars(
|
|||||||
y: number,
|
y: number,
|
||||||
viewportWidth: number,
|
viewportWidth: number,
|
||||||
viewportHeight: number,
|
viewportHeight: number,
|
||||||
scrollX: number,
|
{
|
||||||
scrollY: number,
|
|
||||||
) {
|
|
||||||
const scrollBars = getScrollBars(
|
|
||||||
elements,
|
|
||||||
viewportWidth,
|
|
||||||
viewportHeight,
|
|
||||||
scrollX,
|
scrollX,
|
||||||
scrollY,
|
scrollY,
|
||||||
);
|
zoom,
|
||||||
|
}: {
|
||||||
|
scrollX: number;
|
||||||
|
scrollY: number;
|
||||||
|
zoom: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const scrollBars = getScrollBars(elements, viewportWidth, viewportHeight, {
|
||||||
|
scrollX,
|
||||||
|
scrollY,
|
||||||
|
zoom,
|
||||||
|
});
|
||||||
|
|
||||||
const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [
|
const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [
|
||||||
scrollBars.horizontal,
|
scrollBars.horizontal,
|
||||||
|
@ -5,6 +5,7 @@ export type SceneState = {
|
|||||||
scrollY: number;
|
scrollY: number;
|
||||||
// null indicates transparent bg
|
// null indicates transparent bg
|
||||||
viewBackgroundColor: string | null;
|
viewBackgroundColor: string | null;
|
||||||
|
zoom: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SceneScroll = {
|
export type SceneScroll = {
|
||||||
|
30
src/scene/zoom.ts
Normal file
30
src/scene/zoom.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export function getZoomOrigin(canvas: HTMLCanvasElement | null) {
|
||||||
|
if (canvas === null) {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (context === null) {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCanvasWidth = canvas.width / context.getTransform().a;
|
||||||
|
const normalizedCanvasHeight = canvas.height / context.getTransform().d;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: normalizedCanvasWidth / 2,
|
||||||
|
y: normalizedCanvasHeight / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoomTranslation(canvas: HTMLCanvasElement, zoom: number) {
|
||||||
|
const diffMiddleOfTheCanvas = {
|
||||||
|
x: (canvas.width / 2) * (zoom - 1),
|
||||||
|
y: (canvas.height / 2) * (zoom - 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Due to JavaScript float precision, we fix to fix decimals count to have symmetric zoom
|
||||||
|
return {
|
||||||
|
x: parseFloat(diffMiddleOfTheCanvas.x.toFixed(8)),
|
||||||
|
y: parseFloat(diffMiddleOfTheCanvas.y.toFixed(8)),
|
||||||
|
};
|
||||||
|
}
|
@ -142,6 +142,28 @@ button,
|
|||||||
justify-self: flex-end;
|
justify-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.App-menu_bottom {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: flex-start;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-menu_bottom > * {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-menu_bottom > *:first-child {
|
||||||
|
justify-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-menu_bottom > *:last-child {
|
||||||
|
justify-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.App-menu_left {
|
.App-menu_left {
|
||||||
grid-template-rows: 1fr auto 1fr;
|
grid-template-rows: 1fr auto 1fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -28,4 +28,5 @@ export type AppState = {
|
|||||||
name: string;
|
name: string;
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
isResizing: boolean;
|
isResizing: boolean;
|
||||||
|
zoom: number;
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user