Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0458834681 | |||
| 508f16dc04 | |||
| c1b310c56b | |||
| d4900e8f19 | |||
| caf2db934c | |||
| 60e3801691 | |||
| 80f3b75d42 | |||
| dc812bee19 | |||
| 01e83cc9a5 | |||
| 813f9b702e | |||
| fd39712ba6 | |||
| b46ca0192b | |||
| 72b7c937b1 | |||
| d107215564 | |||
| 6959a363f0 | |||
| 5a11c70714 | |||
| 6ff56c36e3 | |||
| 51ea184938 | |||
| 21fff26d31 |
@@ -8,3 +8,4 @@ public/workbox
|
||||
packages/excalidraw/types
|
||||
examples/**/public
|
||||
dev-dist
|
||||
coverage
|
||||
|
||||
@@ -20,7 +20,7 @@ exportToCanvas({<br/>
|
||||
getDimensions,<br/>
|
||||
files,<br/>
|
||||
exportPadding?: number;<br/>
|
||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
|
||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
|
||||
</pre>
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|
||||
+41
-22
@@ -1547,7 +1547,7 @@
|
||||
"@docusaurus/theme-search-algolia" "2.2.0"
|
||||
"@docusaurus/types" "2.2.0"
|
||||
|
||||
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
"@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
braces@^3.0.2, braces@~3.0.2:
|
||||
braces@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
|
||||
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
|
||||
braces@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||
@@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
fill-range@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
|
||||
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
finalhandler@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
|
||||
@@ -5207,11 +5221,11 @@ methods@~1.1.2:
|
||||
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
|
||||
|
||||
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
|
||||
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||
dependencies:
|
||||
braces "^3.0.2"
|
||||
braces "^3.0.3"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
|
||||
@@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
|
||||
strip-ansi "^6.0.1"
|
||||
text-table "^0.2.0"
|
||||
|
||||
react-dom@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
||||
react-dom@18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-error-overlay@^6.0.11:
|
||||
version "6.0.11"
|
||||
@@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.3"
|
||||
|
||||
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-router-config@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
||||
@@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
|
||||
use-composed-ref "^1.3.0"
|
||||
use-latest "^1.2.1"
|
||||
|
||||
react@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||
react@18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
readable-stream@^2.0.1:
|
||||
version "2.3.7"
|
||||
@@ -6664,13 +6684,12 @@ sax@^1.2.4:
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||
|
||||
scheduler@^0.20.2:
|
||||
version "0.20.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
|
||||
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
|
||||
scheduler@^0.23.0:
|
||||
version "0.23.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
|
||||
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
schema-utils@2.7.0:
|
||||
version "2.7.0"
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ import type {
|
||||
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
||||
|
||||
import "./App.scss";
|
||||
import "./ExampleApp.scss";
|
||||
|
||||
type Comment = {
|
||||
x: number;
|
||||
@@ -73,7 +73,7 @@ export interface AppProps {
|
||||
excalidrawLib: typeof TExcalidraw;
|
||||
}
|
||||
|
||||
export default function App({
|
||||
export default function ExampleApp({
|
||||
appTitle,
|
||||
useCustom,
|
||||
customArgs,
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import App from "../../components/App";
|
||||
import App from "../../components/ExampleApp";
|
||||
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import App from "../components/App";
|
||||
import App from "../components/ExampleApp";
|
||||
import React, { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
|
||||
/>
|
||||
)}
|
||||
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||
import {
|
||||
CANVAS_SEARCH_TAB,
|
||||
DEFAULT_SIDEBAR,
|
||||
} from "../../packages/excalidraw/constants";
|
||||
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
|
||||
appState: AppState,
|
||||
) => {
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
if (
|
||||
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||
) {
|
||||
_appState.openSidebar = null;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
|
||||
+8
-7
@@ -21,8 +21,8 @@
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
"@vitest/ui": "0.32.2",
|
||||
"@vitest/coverage-v8": "2.0.5",
|
||||
"@vitest/ui": "2.0.5",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "16.0.1",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
@@ -37,12 +37,12 @@
|
||||
"rewire": "6.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.6.1",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
"vite-plugin-pwa": "0.17.4",
|
||||
"vite-plugin-svgr": "2.4.0",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
"vite-plugin-svgr": "4.2.0",
|
||||
"vitest": "2.0.5",
|
||||
"vitest-canvas-mock": "0.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.0.0 - 20.x.x"
|
||||
@@ -83,6 +83,7 @@
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.0"
|
||||
"@types/react": "18.2.0",
|
||||
"strip-ansi": "6.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import type { AppState } from "../types";
|
||||
import type { AppState, Offsets } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { clamp } from "../../math";
|
||||
import { clamp, roundToStep } from "../../math";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
@@ -259,89 +259,85 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
const adjustedZoomValue =
|
||||
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
|
||||
|
||||
const zoomAdjustedToSteps =
|
||||
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||
|
||||
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
|
||||
return Math.min(adjustedZoomValue, 1);
|
||||
};
|
||||
|
||||
export const zoomToFitBounds = ({
|
||||
bounds,
|
||||
appState,
|
||||
canvasOffsets,
|
||||
fitToViewport = false,
|
||||
viewportZoomFactor = 1,
|
||||
minZoom = -Infinity,
|
||||
maxZoom = Infinity,
|
||||
}: {
|
||||
bounds: SceneBounds;
|
||||
canvasOffsets?: Offsets;
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
|
||||
let newZoomValue;
|
||||
let scrollX;
|
||||
let scrollY;
|
||||
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
|
||||
const canvasOffsetTop = canvasOffsets?.top ?? 0;
|
||||
const canvasOffsetRight = canvasOffsets?.right ?? 0;
|
||||
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
|
||||
|
||||
const effectiveCanvasWidth =
|
||||
appState.width - canvasOffsetLeft - canvasOffsetRight;
|
||||
const effectiveCanvasHeight =
|
||||
appState.height - canvasOffsetTop - canvasOffsetBottom;
|
||||
|
||||
let adjustedZoomValue;
|
||||
|
||||
if (fitToViewport) {
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
|
||||
newZoomValue =
|
||||
adjustedZoomValue =
|
||||
Math.min(
|
||||
appState.width / commonBoundsWidth,
|
||||
appState.height / commonBoundsHeight,
|
||||
) * clamp(viewportZoomFactor, 0.1, 1);
|
||||
|
||||
newZoomValue = getNormalizedZoom(newZoomValue);
|
||||
|
||||
let appStateWidth = appState.width;
|
||||
|
||||
if (appState.openSidebar) {
|
||||
const sidebarDOMElem = document.querySelector(
|
||||
".sidebar",
|
||||
) as HTMLElement | null;
|
||||
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
appStateWidth = !isRTL
|
||||
? appState.width - sidebarWidth
|
||||
: appState.width + sidebarWidth;
|
||||
}
|
||||
|
||||
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
|
||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||
effectiveCanvasWidth / commonBoundsWidth,
|
||||
effectiveCanvasHeight / commonBoundsHeight,
|
||||
) * viewportZoomFactor;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(
|
||||
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
|
||||
bounds,
|
||||
{
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
width: effectiveCanvasWidth,
|
||||
height: effectiveCanvasHeight,
|
||||
},
|
||||
viewportZoomFactor,
|
||||
);
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
scrollX = centerScroll.scrollX;
|
||||
scrollY = centerScroll.scrollY;
|
||||
}
|
||||
|
||||
const newZoomValue = getNormalizedZoom(
|
||||
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
|
||||
);
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
offsets: canvasOffsets,
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
scrollX,
|
||||
scrollY,
|
||||
scrollX: centerScroll.scrollX,
|
||||
scrollY: centerScroll.scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
@@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
|
||||
};
|
||||
|
||||
export const zoomToFit = ({
|
||||
canvasOffsets,
|
||||
targetElements,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
}: {
|
||||
canvasOffsets?: Offsets;
|
||||
targetElements: readonly ExcalidrawElement[];
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||
|
||||
return zoomToFitBounds({
|
||||
canvasOffsets,
|
||||
bounds: commonBounds,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||
@@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: true,
|
||||
viewportZoomFactor: 0.7,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE this action should use shift-2 per figma, alas
|
||||
@@ -430,7 +436,7 @@ export const actionZoomToFit = register({
|
||||
icon: zoomAreaIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
zoomToFit({
|
||||
targetElements: elements,
|
||||
appState: {
|
||||
@@ -438,6 +444,7 @@ export const actionZoomToFit = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
}),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
|
||||
@@ -217,6 +217,7 @@ export const actionFinalize = register({
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
style={{ pointerEvents: "all" }}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import type { AppState } from "../types";
|
||||
import { searchIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
||||
|
||||
export const actionToggleSearchMenu = register({
|
||||
name: "searchMenu",
|
||||
icon: searchIcon,
|
||||
keywords: ["search", "find"],
|
||||
label: "search.title",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "search_menu",
|
||||
action: "toggle",
|
||||
predicate: (appState) => appState.gridModeEnabled,
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
if (
|
||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||
) {
|
||||
const searchInput =
|
||||
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||
);
|
||||
|
||||
if (searchInput?.matches(":focus")) {
|
||||
return {
|
||||
appState: { ...appState, openSidebar: null },
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
searchInput?.focus();
|
||||
searchInput?.select();
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
|
||||
openDialog: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||
predicate: (element, appState, props) => {
|
||||
return props.gridModeEnabled === undefined;
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
|
||||
});
|
||||
@@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "./actionLink";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
||||
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
||||
|
||||
@@ -51,7 +51,8 @@ export type ShortcutName =
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
| "commandPalette";
|
||||
| "commandPalette"
|
||||
| "searchMenu";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
|
||||
@@ -137,7 +137,8 @@ export type ActionName =
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
| "elementStats"
|
||||
| "searchMenu";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -191,7 +192,8 @@ export interface Action {
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
| "hyperlink"
|
||||
| "search_menu";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
|
||||
@@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit<
|
||||
objectsSnapModeEnabled: false,
|
||||
userToFollow: null,
|
||||
followedBy: new Set(),
|
||||
searchMatches: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -236,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
userToFollow: { browser: false, export: false, server: false },
|
||||
followedBy: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
import oc from "open-color";
|
||||
import type { Merge } from "./utility-types";
|
||||
import { clamp } from "../math/utils";
|
||||
import tinycolor from "tinycolor2";
|
||||
import { degreesToRadians } from "../math/angle";
|
||||
import type { Degrees } from "../math/types";
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
degrees: Degrees,
|
||||
): { r: number; g: number; b: number } {
|
||||
// normalize
|
||||
const r = red / 255;
|
||||
const g = green / 255;
|
||||
const b = blue / 255;
|
||||
|
||||
// Convert degrees to radians
|
||||
const a = degreesToRadians(degrees);
|
||||
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
|
||||
// rotation matrix
|
||||
const matrix = [
|
||||
0.213 + c * 0.787 - s * 0.213,
|
||||
0.715 - c * 0.715 - s * 0.715,
|
||||
0.072 - c * 0.072 + s * 0.928,
|
||||
0.213 - c * 0.213 + s * 0.143,
|
||||
0.715 + c * 0.285 + s * 0.14,
|
||||
0.072 - c * 0.072 - s * 0.283,
|
||||
0.213 - c * 0.213 - s * 0.787,
|
||||
0.715 - c * 0.715 + s * 0.715,
|
||||
0.072 + c * 0.928 + s * 0.072,
|
||||
];
|
||||
|
||||
// transform
|
||||
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
|
||||
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
|
||||
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
|
||||
|
||||
// clamp the values to [0, 1] range and convert back to [0, 255]
|
||||
return {
|
||||
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
|
||||
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
|
||||
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
const cssInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
percent: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = clamp(percent, 0, 100) / 100;
|
||||
|
||||
// Function to invert a single color component
|
||||
const invertComponent = (color: number): number => {
|
||||
// Apply the invert formula
|
||||
const inverted = color * (1 - p) + (255 - color) * p;
|
||||
// Round to the nearest integer and clamp to [0, 255]
|
||||
return Math.round(clamp(inverted, 0, 255));
|
||||
};
|
||||
|
||||
// Calculate the inverted RGB components
|
||||
const invertedR = invertComponent(r);
|
||||
const invertedG = invertComponent(g);
|
||||
const invertedB = invertComponent(b);
|
||||
|
||||
return { r: invertedR, g: invertedG, b: invertedB };
|
||||
};
|
||||
|
||||
export const applyDarkModeFilter = (color: string) => {
|
||||
let tc = tinycolor(color);
|
||||
|
||||
const _alpha = tc._a;
|
||||
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
tc = tinycolor(cssInvert(tc._r, tc._g, tc._b, 93));
|
||||
tc = tinycolor(cssHueRotate(tc._r, tc._g, tc._b, 180 as Degrees));
|
||||
tc.setAlpha(_alpha);
|
||||
|
||||
return tc.toHex8String();
|
||||
};
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
|
||||
@@ -259,6 +259,7 @@ import type {
|
||||
ElementsPendingErasure,
|
||||
GenerateDiagramToCode,
|
||||
NullableGridSize,
|
||||
Offsets,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@@ -440,6 +441,7 @@ import {
|
||||
FlowChartNavigator,
|
||||
getLinkDirectionFromKey,
|
||||
} from "../element/flowchart";
|
||||
import { searchItemInFocusAtom } from "./SearchMenu";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import { point, pointDistance, vector } from "../../math";
|
||||
|
||||
@@ -548,6 +550,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public scene: Scene;
|
||||
public fonts: Fonts;
|
||||
public renderer: Renderer;
|
||||
public visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
private resizeObserver: ResizeObserver | undefined;
|
||||
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
||||
public library: AppClassProperties["library"];
|
||||
@@ -555,7 +558,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public id: string;
|
||||
private store: Store;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
public excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
id: string;
|
||||
};
|
||||
@@ -682,6 +685,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.rc = rough.canvas(this.canvas);
|
||||
this.renderer = new Renderer(this.scene);
|
||||
this.visibleElements = [];
|
||||
|
||||
this.store = new Store();
|
||||
this.history = new History();
|
||||
@@ -1480,6 +1484,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
newElementId: this.state.newElement?.id,
|
||||
pendingImageElementId: this.state.pendingImageElementId,
|
||||
});
|
||||
this.visibleElements = visibleElements;
|
||||
|
||||
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
||||
|
||||
@@ -1695,6 +1700,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elementsPendingErasure: this.elementsPendingErasure,
|
||||
pendingFlowchartNodes:
|
||||
this.flowChartCreator.pendingNodes,
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
{this.state.newElement && (
|
||||
@@ -1715,6 +1721,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elementsPendingErasure:
|
||||
this.elementsPendingErasure,
|
||||
pendingFlowchartNodes: null,
|
||||
theme: this.state.theme,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -2295,6 +2302,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
// clear the shape and image cache so that any images in initialData
|
||||
// can be loaded fresh
|
||||
this.clearImageShapeCache();
|
||||
// FontFaceSet loadingdone event we listen on may not always
|
||||
// fire (looking at you Safari), so on init we manually load all
|
||||
// fonts and rerender scene text elements once done. This also
|
||||
@@ -2360,6 +2370,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return false;
|
||||
};
|
||||
|
||||
private clearImageShapeCache(filesMap?: BinaryFiles) {
|
||||
const files = filesMap ?? this.files;
|
||||
this.scene.getNonDeletedElements().forEach((element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
this.imageCache.delete(element.fileId);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.excalidrawContainerValue.container =
|
||||
@@ -2677,6 +2697,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||
});
|
||||
}
|
||||
if (prevState.theme !== this.state.theme) {
|
||||
this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.forEach((element) => ShapeCache.delete(element));
|
||||
}
|
||||
if (
|
||||
this.state.activeTool.type === "eraser" &&
|
||||
prevState.theme !== this.state.theme
|
||||
@@ -3215,6 +3240,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (opts.fitToContent) {
|
||||
this.scrollToContent(newElements, {
|
||||
fitToContent: true,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3527,7 +3553,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
target:
|
||||
| ExcalidrawElement
|
||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||
opts?:
|
||||
opts?: (
|
||||
| {
|
||||
fitToContent?: boolean;
|
||||
fitToViewport?: never;
|
||||
@@ -3544,7 +3570,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
viewportZoomFactor?: number;
|
||||
animate?: boolean;
|
||||
duration?: number;
|
||||
},
|
||||
}
|
||||
) & {
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
canvasOffsets?: Offsets;
|
||||
},
|
||||
) => {
|
||||
this.cancelInProgressAnimation?.();
|
||||
|
||||
@@ -3557,10 +3588,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (opts?.fitToContent || opts?.fitToViewport) {
|
||||
const { appState } = zoomToFit({
|
||||
canvasOffsets: opts.canvasOffsets,
|
||||
targetElements,
|
||||
appState: this.state,
|
||||
fitToViewport: !!opts?.fitToViewport,
|
||||
viewportZoomFactor: opts?.viewportZoomFactor,
|
||||
minZoom: opts?.minZoom,
|
||||
maxZoom: opts?.maxZoom,
|
||||
});
|
||||
zoom = appState.zoom;
|
||||
scrollX = appState.scrollX;
|
||||
@@ -3674,15 +3708,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
this.files = { ...this.files, ...Object.fromEntries(filesMap) };
|
||||
|
||||
this.scene.getNonDeletedElements().forEach((element) => {
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
filesMap.has(element.fileId)
|
||||
) {
|
||||
this.imageCache.delete(element.fileId);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
});
|
||||
this.clearImageShapeCache(Object.fromEntries(filesMap));
|
||||
this.scene.triggerUpdate();
|
||||
|
||||
this.addNewImagesToImageCache();
|
||||
@@ -3796,40 +3822,42 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
|
||||
private getEditorUIOffsets = (): {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
} => {
|
||||
public getEditorUIOffsets = (): Offsets => {
|
||||
const toolbarBottom =
|
||||
this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-toolbar")
|
||||
?.getBoundingClientRect()?.bottom ?? 0;
|
||||
const sidebarWidth = Math.max(
|
||||
this.excalidrawContainerRef?.current
|
||||
?.querySelector(".default-sidebar")
|
||||
?.getBoundingClientRect()?.width ?? 0,
|
||||
);
|
||||
const propertiesPanelWidth = Math.max(
|
||||
this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-menu__left")
|
||||
?.getBoundingClientRect()?.width ?? 0,
|
||||
0,
|
||||
);
|
||||
const sidebarRect = this.excalidrawContainerRef?.current
|
||||
?.querySelector(".sidebar")
|
||||
?.getBoundingClientRect();
|
||||
const propertiesPanelRect = this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-menu__left")
|
||||
?.getBoundingClientRect();
|
||||
|
||||
const PADDING = 16;
|
||||
|
||||
return getLanguage().rtl
|
||||
? {
|
||||
top: toolbarBottom,
|
||||
right: propertiesPanelWidth,
|
||||
bottom: 0,
|
||||
left: sidebarWidth,
|
||||
top: toolbarBottom + PADDING,
|
||||
right:
|
||||
Math.max(
|
||||
this.state.width -
|
||||
(propertiesPanelRect?.left ?? this.state.width),
|
||||
0,
|
||||
) + PADDING,
|
||||
bottom: PADDING,
|
||||
left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
|
||||
}
|
||||
: {
|
||||
top: toolbarBottom,
|
||||
right: sidebarWidth,
|
||||
bottom: 0,
|
||||
left: propertiesPanelWidth,
|
||||
top: toolbarBottom + PADDING,
|
||||
right: Math.max(
|
||||
this.state.width -
|
||||
(sidebarRect?.left ?? this.state.width) +
|
||||
PADDING,
|
||||
0,
|
||||
),
|
||||
bottom: PADDING,
|
||||
left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3914,7 +3942,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
fitToContent: true,
|
||||
viewportZoomFactor: 0.8,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3970,6 +3998,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scrollToContent(nextNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4402,6 +4431,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scrollToContent(firstNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5969,6 +5999,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
||||
this.maybeUnfollowRemoteUser();
|
||||
|
||||
if (this.state.searchMatches) {
|
||||
this.setState((state) => ({
|
||||
searchMatches: state.searchMatches.map((searchMatch) => ({
|
||||
...searchMatch,
|
||||
focus: false,
|
||||
})),
|
||||
}));
|
||||
jotaiStore.set(searchItemInFocusAtom, null);
|
||||
}
|
||||
|
||||
// since contextMenu options are potentially evaluated on each render,
|
||||
// and an contextMenu action may depend on selection state, we must
|
||||
// close the contextMenu before we update the selection on pointerDown
|
||||
@@ -6397,8 +6437,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
isPanning = true;
|
||||
|
||||
// due to event.preventDefault below, container wouldn't get focus
|
||||
// automatically
|
||||
this.focusContainer();
|
||||
|
||||
// preventing defualt while text editing messes with cursor/focus
|
||||
if (!this.state.editingTextElement) {
|
||||
// preventing defualt while text editing messes with cursor/focus
|
||||
// necessary to prevent browser from scrolling the page if excalidraw
|
||||
// not full-page #4489
|
||||
//
|
||||
// as such, the above is broken when panning canvas while in wysiwyg
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
|
||||
import { SHAPES } from "../../shapes";
|
||||
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
||||
import { useStableCallback } from "../../hooks/useStableCallback";
|
||||
import { actionClearCanvas, actionLink } from "../../actions";
|
||||
import {
|
||||
actionClearCanvas,
|
||||
actionLink,
|
||||
actionToggleSearchMenu,
|
||||
} from "../../actions";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import type { CommandPaletteItem } from "./types";
|
||||
@@ -382,6 +386,15 @@ function CommandPaletteInner({
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("search.title"),
|
||||
category: DEFAULT_CATEGORIES.app,
|
||||
icon: searchIcon,
|
||||
viewMode: true,
|
||||
perform: () => {
|
||||
actionManager.executeAction(actionToggleSearchMenu);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.changeStroke"),
|
||||
keywords: ["color", "outline"],
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import clsx from "clsx";
|
||||
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
|
||||
import {
|
||||
CANVAS_SEARCH_TAB,
|
||||
DEFAULT_SIDEBAR,
|
||||
LIBRARY_SIDEBAR_TAB,
|
||||
} from "../constants";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { t } from "../i18n";
|
||||
import type { MarkOptional, Merge } from "../utility-types";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import "../components/dropdownMenu/DropdownMenu.scss";
|
||||
import { SearchMenu } from "./SearchMenu";
|
||||
import { LibraryIcon, searchIcon } from "./icons";
|
||||
|
||||
const DefaultSidebarTrigger = withInternalFallback(
|
||||
"DefaultSidebarTrigger",
|
||||
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
|
||||
);
|
||||
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
||||
|
||||
const DefaultTabTriggers = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
return (
|
||||
<DefaultSidebarTabTriggersTunnel.In>
|
||||
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
|
||||
{children}
|
||||
</DefaultSidebarTabTriggersTunnel.In>
|
||||
);
|
||||
};
|
||||
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
|
||||
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
|
||||
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
{...rest}
|
||||
name="default"
|
||||
key="default"
|
||||
className={clsx("default-sidebar", className)}
|
||||
docked={docked ?? appState.defaultSidebarDockedPreference}
|
||||
docked={
|
||||
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
|
||||
}
|
||||
onDock={
|
||||
// `onDock=false` disables docking.
|
||||
// if `docked` passed, but no onDock passed, disable manual docking.
|
||||
onDock === false || (!onDock && docked != null)
|
||||
isForceDocked || onDock === false || (!onDock && docked != null)
|
||||
? undefined
|
||||
: // compose to allow the host app to listen on default behavior
|
||||
composeEventHandlers(onDock, (docked) => {
|
||||
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
|
||||
>
|
||||
<Sidebar.Tabs>
|
||||
<Sidebar.Header>
|
||||
{rest.__fallback && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
fontSize: "1.2em",
|
||||
fontWeight: "bold",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
paddingRight: "1em",
|
||||
}}
|
||||
>
|
||||
{t("toolBar.library")}
|
||||
</div>
|
||||
)}
|
||||
<DefaultSidebarTabTriggersTunnel.Out />
|
||||
<Sidebar.TabTriggers>
|
||||
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
|
||||
{searchIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
|
||||
{LibraryIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<DefaultSidebarTabTriggersTunnel.Out />
|
||||
</Sidebar.TabTriggers>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
||||
<LibraryMenu />
|
||||
</Sidebar.Tab>
|
||||
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
|
||||
<SearchMenu />
|
||||
</Sidebar.Tab>
|
||||
{children}
|
||||
</Sidebar.Tabs>
|
||||
</Sidebar>
|
||||
|
||||
@@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("stats.fullTitle")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("search.title")}
|
||||
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("commandPalette.title")}
|
||||
shortcuts={
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
|
||||
import "./HintViewer.scss";
|
||||
import { isNodeInFlowchart } from "../element/flowchart";
|
||||
import { isGridModeEnabled } from "../snapping";
|
||||
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: UIAppState;
|
||||
@@ -30,6 +31,14 @@ const getHints = ({
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (
|
||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
||||
appState.searchMatches?.length
|
||||
) {
|
||||
return t("hints.dismissSearch");
|
||||
}
|
||||
|
||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -53,9 +53,6 @@ import { LibraryIcon } from "./icons";
|
||||
import { UIAppStateContext } from "../context/ui-appState";
|
||||
import { DefaultSidebar } from "./DefaultSidebar";
|
||||
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
@@ -64,6 +61,9 @@ import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: UIAppState;
|
||||
@@ -99,6 +99,7 @@ const DefaultMainMenu: React.FC<{
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
)}
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__search {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.layer-ui__search-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
.ExcTextField {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.ExcTextField__input {
|
||||
background-color: #f5f5f9;
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
background-color: #31303b;
|
||||
}
|
||||
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 0;
|
||||
|
||||
input::placeholder {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__search-count {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 8px 0 8px;
|
||||
margin: 0 0.75rem 0.25rem 0.75rem;
|
||||
font-size: 0.8em;
|
||||
|
||||
.result-nav {
|
||||
display: flex;
|
||||
|
||||
.result-nav-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
--button-border: transparent;
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
|
||||
&:first {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__search-result-container {
|
||||
overflow-y: auto;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.layer-ui__result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.25rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
|
||||
margin: 0 0.75rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
.text-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
flex: 1;
|
||||
max-height: 48px;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
import { Fragment, memo, useEffect, useRef, useState } from "react";
|
||||
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
|
||||
import { TextField } from "./TextField";
|
||||
import { Button } from "./Button";
|
||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||
import { debounce } from "lodash";
|
||||
import type { AppClassProperties } from "../types";
|
||||
import { isTextElement, newTextElement } from "../element";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { addEventListener, getFontString } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import clsx from "clsx";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { t } from "../i18n";
|
||||
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
||||
import { randomInteger } from "../random";
|
||||
import { CLASSES, EVENT } from "../constants";
|
||||
import { useStable } from "../hooks/useStable";
|
||||
|
||||
import "./SearchMenu.scss";
|
||||
import { round } from "../../math";
|
||||
|
||||
const searchQueryAtom = atom<string>("");
|
||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
||||
|
||||
const SEARCH_DEBOUNCE = 350;
|
||||
|
||||
type SearchMatchItem = {
|
||||
textElement: ExcalidrawTextElement;
|
||||
searchQuery: SearchQuery;
|
||||
index: number;
|
||||
preview: {
|
||||
indexInSearchQuery: number;
|
||||
previewText: string;
|
||||
moreBefore: boolean;
|
||||
moreAfter: boolean;
|
||||
};
|
||||
matchedLines: {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
type SearchMatches = {
|
||||
nonce: number | null;
|
||||
items: SearchMatchItem[];
|
||||
};
|
||||
|
||||
type SearchQuery = string & { _brand: "SearchQuery" };
|
||||
|
||||
export const SearchMenu = () => {
|
||||
const app = useApp();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
||||
const searchQuery = inputValue.trim() as SearchQuery;
|
||||
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
|
||||
nonce: null,
|
||||
items: [],
|
||||
});
|
||||
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
||||
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const [focusIndex, setFocusIndex] = useAtom(
|
||||
searchItemInFocusAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearching) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
searchQuery !== searchedQueryRef.current ||
|
||||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
|
||||
) {
|
||||
searchedQueryRef.current = null;
|
||||
handleSearch(searchQuery, app, (matchItems, index) => {
|
||||
setSearchMatches({
|
||||
nonce: randomInteger(),
|
||||
items: matchItems,
|
||||
});
|
||||
searchedQueryRef.current = searchQuery;
|
||||
lastSceneNonceRef.current = app.scene.getSceneNonce();
|
||||
setAppState({
|
||||
searchMatches: matchItems.map((searchMatch) => ({
|
||||
id: searchMatch.textElement.id,
|
||||
focus: false,
|
||||
matchedLines: searchMatch.matchedLines,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isSearching,
|
||||
searchQuery,
|
||||
elementsMap,
|
||||
app,
|
||||
setAppState,
|
||||
setFocusIndex,
|
||||
lastSceneNonceRef,
|
||||
]);
|
||||
|
||||
const goToNextItem = () => {
|
||||
if (searchMatches.items.length > 0) {
|
||||
setFocusIndex((focusIndex) => {
|
||||
if (focusIndex === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (focusIndex + 1) % searchMatches.items.length;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousItem = () => {
|
||||
if (searchMatches.items.length > 0) {
|
||||
setFocusIndex((focusIndex) => {
|
||||
if (focusIndex === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return focusIndex - 1 < 0
|
||||
? searchMatches.items.length - 1
|
||||
: focusIndex - 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setAppState((state) => {
|
||||
return {
|
||||
searchMatches: state.searchMatches.map((match, index) => {
|
||||
if (index === focusIndex) {
|
||||
return { ...match, focus: true };
|
||||
}
|
||||
return { ...match, focus: false };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, [focusIndex, setAppState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchMatches.items.length > 0 && focusIndex !== null) {
|
||||
const match = searchMatches.items[focusIndex];
|
||||
|
||||
if (match) {
|
||||
const zoomValue = app.state.zoom.value;
|
||||
|
||||
const matchAsElement = newTextElement({
|
||||
text: match.searchQuery,
|
||||
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
|
||||
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
|
||||
width: match.matchedLines[0]?.width,
|
||||
height: match.matchedLines[0]?.height,
|
||||
fontSize: match.textElement.fontSize,
|
||||
fontFamily: match.textElement.fontFamily,
|
||||
});
|
||||
|
||||
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
|
||||
|
||||
const fontSize = match.textElement.fontSize;
|
||||
const isTextTiny =
|
||||
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
|
||||
|
||||
if (
|
||||
!isElementCompletelyInViewport(
|
||||
[matchAsElement],
|
||||
app.canvas.width / window.devicePixelRatio,
|
||||
app.canvas.height / window.devicePixelRatio,
|
||||
{
|
||||
offsetLeft: app.state.offsetLeft,
|
||||
offsetTop: app.state.offsetTop,
|
||||
scrollX: app.state.scrollX,
|
||||
scrollY: app.state.scrollY,
|
||||
zoom: app.state.zoom,
|
||||
},
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.getEditorUIOffsets(),
|
||||
) ||
|
||||
isTextTiny
|
||||
) {
|
||||
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
|
||||
|
||||
if (isTextTiny) {
|
||||
if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
|
||||
zoomOptions = { fitToContent: true };
|
||||
} else {
|
||||
zoomOptions = {
|
||||
fitToViewport: true,
|
||||
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
|
||||
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
zoomOptions = { fitToContent: true };
|
||||
}
|
||||
|
||||
app.scrollToContent(matchAsElement, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
...zoomOptions,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [focusIndex, searchMatches, app]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setFocusIndex(null);
|
||||
searchedQueryRef.current = null;
|
||||
lastSceneNonceRef.current = undefined;
|
||||
setAppState({
|
||||
searchMatches: [],
|
||||
});
|
||||
setIsSearching(false);
|
||||
};
|
||||
}, [setAppState, setFocusIndex]);
|
||||
|
||||
const stableState = useStable({
|
||||
goToNextItem,
|
||||
goToPreviousItem,
|
||||
searchMatches,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const eventHandler = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
!app.state.openDialog &&
|
||||
!app.state.openPopup
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!searchInputRef.current?.matches(":focus")) {
|
||||
if (app.state.openDialog) {
|
||||
setAppState({
|
||||
openDialog: null,
|
||||
});
|
||||
}
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
} else {
|
||||
setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.closest(".layer-ui__search")
|
||||
) {
|
||||
if (stableState.searchMatches.items.length) {
|
||||
if (event.key === KEYS.ENTER) {
|
||||
event.stopPropagation();
|
||||
stableState.goToNextItem();
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_UP) {
|
||||
event.stopPropagation();
|
||||
stableState.goToPreviousItem();
|
||||
} else if (event.key === KEYS.ARROW_DOWN) {
|
||||
event.stopPropagation();
|
||||
stableState.goToNextItem();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// `capture` needed to prevent firing on initial open from App.tsx,
|
||||
// as well as to handle events before App ones
|
||||
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
|
||||
capture: true,
|
||||
});
|
||||
}, [setAppState, stableState, app]);
|
||||
|
||||
const matchCount = `${searchMatches.items.length} ${
|
||||
searchMatches.items.length === 1
|
||||
? t("search.singleResult")
|
||||
: t("search.multipleResults")
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="layer-ui__search">
|
||||
<div className="layer-ui__search-header">
|
||||
<TextField
|
||||
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
|
||||
value={inputValue}
|
||||
ref={searchInputRef}
|
||||
placeholder={t("search.placeholder")}
|
||||
icon={searchIcon}
|
||||
onChange={(value) => {
|
||||
setInputValue(value);
|
||||
setIsSearching(true);
|
||||
const searchQuery = value.trim() as SearchQuery;
|
||||
handleSearch(searchQuery, app, (matchItems, index) => {
|
||||
setSearchMatches({
|
||||
nonce: randomInteger(),
|
||||
items: matchItems,
|
||||
});
|
||||
setFocusIndex(index);
|
||||
searchedQueryRef.current = searchQuery;
|
||||
lastSceneNonceRef.current = app.scene.getSceneNonce();
|
||||
setAppState({
|
||||
searchMatches: matchItems.map((searchMatch) => ({
|
||||
id: searchMatch.textElement.id,
|
||||
focus: false,
|
||||
matchedLines: searchMatch.matchedLines,
|
||||
})),
|
||||
});
|
||||
|
||||
setIsSearching(false);
|
||||
});
|
||||
}}
|
||||
selectOnRender
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="layer-ui__search-count">
|
||||
{searchMatches.items.length > 0 && (
|
||||
<>
|
||||
{focusIndex !== null && focusIndex > -1 ? (
|
||||
<div>
|
||||
{focusIndex + 1} / {matchCount}
|
||||
</div>
|
||||
) : (
|
||||
<div>{matchCount}</div>
|
||||
)}
|
||||
<div className="result-nav">
|
||||
<Button
|
||||
onSelect={() => {
|
||||
goToNextItem();
|
||||
}}
|
||||
className="result-nav-btn"
|
||||
>
|
||||
{collapseDownIcon}
|
||||
</Button>
|
||||
<Button
|
||||
onSelect={() => {
|
||||
goToPreviousItem();
|
||||
}}
|
||||
className="result-nav-btn"
|
||||
>
|
||||
{upIcon}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchMatches.items.length === 0 &&
|
||||
searchQuery &&
|
||||
searchedQueryRef.current && (
|
||||
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MatchList
|
||||
matches={searchMatches}
|
||||
onItemClick={setFocusIndex}
|
||||
focusIndex={focusIndex}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListItem = (props: {
|
||||
preview: SearchMatchItem["preview"];
|
||||
searchQuery: SearchQuery;
|
||||
highlighted: boolean;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const preview = [
|
||||
props.preview.moreBefore ? "..." : "",
|
||||
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
|
||||
props.preview.previewText.slice(
|
||||
props.preview.indexInSearchQuery,
|
||||
props.preview.indexInSearchQuery + props.searchQuery.length,
|
||||
),
|
||||
props.preview.previewText.slice(
|
||||
props.preview.indexInSearchQuery + props.searchQuery.length,
|
||||
),
|
||||
props.preview.moreAfter ? "..." : "",
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={clsx("layer-ui__result-item", {
|
||||
active: props.highlighted,
|
||||
})}
|
||||
onClick={props.onClick}
|
||||
ref={(ref) => {
|
||||
if (props.highlighted) {
|
||||
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="preview-text">
|
||||
{preview.flatMap((text, idx) => (
|
||||
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MatchListProps {
|
||||
matches: SearchMatches;
|
||||
onItemClick: (index: number) => void;
|
||||
focusIndex: number | null;
|
||||
searchQuery: SearchQuery;
|
||||
}
|
||||
|
||||
const MatchListBase = (props: MatchListProps) => {
|
||||
return (
|
||||
<div className="layer-ui__search-result-container">
|
||||
{props.matches.items.map((searchMatch, index) => (
|
||||
<ListItem
|
||||
key={searchMatch.textElement.id + searchMatch.index}
|
||||
searchQuery={props.searchQuery}
|
||||
preview={searchMatch.preview}
|
||||
highlighted={index === props.focusIndex}
|
||||
onClick={() => props.onItemClick(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
|
||||
return (
|
||||
prevProps.matches.nonce === nextProps.matches.nonce &&
|
||||
prevProps.focusIndex === nextProps.focusIndex
|
||||
);
|
||||
};
|
||||
|
||||
const MatchList = memo(MatchListBase, areEqual);
|
||||
|
||||
const getMatchPreview = (
|
||||
text: string,
|
||||
index: number,
|
||||
searchQuery: SearchQuery,
|
||||
) => {
|
||||
const WORDS_BEFORE = 2;
|
||||
const WORDS_AFTER = 5;
|
||||
|
||||
const substrBeforeQuery = text.slice(0, index);
|
||||
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
|
||||
// text = "small", query = "mall", not complete before
|
||||
// text = "small", query = "smal", complete before
|
||||
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
|
||||
const startWordIndex =
|
||||
wordsBeforeQuery.length -
|
||||
WORDS_BEFORE -
|
||||
1 -
|
||||
(isQueryCompleteBefore ? 0 : 1);
|
||||
let wordsBeforeAsString =
|
||||
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
|
||||
(isQueryCompleteBefore ? " " : "");
|
||||
|
||||
const MAX_ALLOWED_CHARS = 20;
|
||||
|
||||
wordsBeforeAsString =
|
||||
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
|
||||
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
|
||||
: wordsBeforeAsString;
|
||||
|
||||
const substrAfterQuery = text.slice(index + searchQuery.length);
|
||||
const wordsAfter = substrAfterQuery.split(/\s+/);
|
||||
// text = "small", query = "mall", complete after
|
||||
// text = "small", query = "smal", not complete after
|
||||
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
|
||||
const numberOfWordsToTake = isQueryCompleteAfter
|
||||
? WORDS_AFTER + 1
|
||||
: WORDS_AFTER;
|
||||
const wordsAfterAsString =
|
||||
(isQueryCompleteAfter ? "" : " ") +
|
||||
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
|
||||
|
||||
return {
|
||||
indexInSearchQuery: wordsBeforeAsString.length,
|
||||
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
|
||||
moreBefore: startWordIndex > 0,
|
||||
moreAfter: wordsAfter.length > numberOfWordsToTake,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeWrappedText = (
|
||||
wrappedText: string,
|
||||
originalText: string,
|
||||
): string => {
|
||||
const wrappedLines = wrappedText.split("\n");
|
||||
const normalizedLines: string[] = [];
|
||||
let originalIndex = 0;
|
||||
|
||||
for (let i = 0; i < wrappedLines.length; i++) {
|
||||
let currentLine = wrappedLines[i];
|
||||
const nextLine = wrappedLines[i + 1];
|
||||
|
||||
if (nextLine) {
|
||||
const nextLineIndexInOriginal = originalText.indexOf(
|
||||
nextLine,
|
||||
originalIndex,
|
||||
);
|
||||
|
||||
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
|
||||
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
|
||||
|
||||
while (j > 0) {
|
||||
currentLine += " ";
|
||||
j--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizedLines.push(currentLine);
|
||||
originalIndex = originalIndex + currentLine.length;
|
||||
}
|
||||
|
||||
return normalizedLines.join("\n");
|
||||
};
|
||||
|
||||
const getMatchedLines = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
searchQuery: SearchQuery,
|
||||
index: number,
|
||||
) => {
|
||||
const normalizedText = normalizeWrappedText(
|
||||
textElement.text,
|
||||
textElement.originalText,
|
||||
);
|
||||
|
||||
const lines = normalizedText.split("\n");
|
||||
|
||||
const lineIndexRanges = [];
|
||||
let currentIndex = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const startIndex = currentIndex;
|
||||
const endIndex = startIndex + line.length - 1;
|
||||
|
||||
lineIndexRanges.push({
|
||||
line,
|
||||
startIndex,
|
||||
endIndex,
|
||||
lineNumber,
|
||||
});
|
||||
|
||||
// Move to the next line's start index
|
||||
currentIndex = endIndex + 1;
|
||||
lineNumber++;
|
||||
}
|
||||
|
||||
let startIndex = index;
|
||||
let remainingQuery = textElement.originalText.slice(
|
||||
index,
|
||||
index + searchQuery.length,
|
||||
);
|
||||
const matchedLines: {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[] = [];
|
||||
|
||||
for (const lineIndexRange of lineIndexRanges) {
|
||||
if (remainingQuery === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
startIndex >= lineIndexRange.startIndex &&
|
||||
startIndex <= lineIndexRange.endIndex
|
||||
) {
|
||||
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
|
||||
const textToStart = lineIndexRange.line.slice(
|
||||
0,
|
||||
startIndex - lineIndexRange.startIndex,
|
||||
);
|
||||
|
||||
const matchedWord = remainingQuery.slice(0, matchCapacity);
|
||||
remainingQuery = remainingQuery.slice(matchCapacity);
|
||||
|
||||
const offset = measureText(
|
||||
textToStart,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
true,
|
||||
);
|
||||
|
||||
// measureText returns a non-zero width for the empty string
|
||||
// which is not what we're after here, hence the check and the correction
|
||||
if (textToStart === "") {
|
||||
offset.width = 0;
|
||||
}
|
||||
|
||||
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
|
||||
const lineLength = measureText(
|
||||
lineIndexRange.line,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
true,
|
||||
);
|
||||
|
||||
const spaceToStart =
|
||||
textElement.textAlign === "center"
|
||||
? (textElement.width - lineLength.width) / 2
|
||||
: textElement.width - lineLength.width;
|
||||
offset.width += spaceToStart;
|
||||
}
|
||||
|
||||
const { width, height } = measureText(
|
||||
matchedWord,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
const offsetX = offset.width;
|
||||
const offsetY = lineIndexRange.lineNumber * offset.height;
|
||||
|
||||
matchedLines.push({
|
||||
offsetX,
|
||||
offsetY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
startIndex += matchCapacity;
|
||||
}
|
||||
}
|
||||
|
||||
return matchedLines;
|
||||
};
|
||||
|
||||
const escapeSpecialCharacters = (string: string) => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
||||
};
|
||||
|
||||
const handleSearch = debounce(
|
||||
(
|
||||
searchQuery: SearchQuery,
|
||||
app: AppClassProperties,
|
||||
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
|
||||
) => {
|
||||
if (!searchQuery || searchQuery === "") {
|
||||
cb([], null);
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const texts = elements.filter((el) =>
|
||||
isTextElement(el),
|
||||
) as ExcalidrawTextElement[];
|
||||
|
||||
texts.sort((a, b) => a.y - b.y);
|
||||
|
||||
const matchItems: SearchMatchItem[] = [];
|
||||
|
||||
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
|
||||
|
||||
for (const textEl of texts) {
|
||||
let match = null;
|
||||
const text = textEl.originalText;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const preview = getMatchPreview(text, match.index, searchQuery);
|
||||
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
|
||||
|
||||
if (matchedLines.length > 0) {
|
||||
matchItems.push({
|
||||
textElement: textEl,
|
||||
searchQuery,
|
||||
preview,
|
||||
index: match.index,
|
||||
matchedLines,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visibleIds = new Set(
|
||||
app.visibleElements.map((visibleElement) => visibleElement.id),
|
||||
);
|
||||
|
||||
const focusIndex =
|
||||
matchItems.findIndex((matchItem) =>
|
||||
visibleIds.has(matchItem.textElement.id),
|
||||
) ?? null;
|
||||
|
||||
cb(matchItems, focusIndex);
|
||||
},
|
||||
SEARCH_DEBOUNCE,
|
||||
);
|
||||
@@ -3,16 +3,29 @@
|
||||
.excalidraw {
|
||||
--ExcTextField--color: var(--color-on-surface);
|
||||
--ExcTextField--label-color: var(--color-on-surface);
|
||||
--ExcTextField--background: transparent;
|
||||
--ExcTextField--background: var(--color-surface-low);
|
||||
--ExcTextField--readonly--background: var(--color-surface-high);
|
||||
--ExcTextField--readonly--color: var(--color-on-surface);
|
||||
--ExcTextField--border: var(--color-border-outline);
|
||||
--ExcTextField--border: var(--color-gray-20);
|
||||
--ExcTextField--readonly--border: var(--color-border-outline-variant);
|
||||
--ExcTextField--border-hover: var(--color-brand-hover);
|
||||
--ExcTextField--border-active: var(--color-brand-active);
|
||||
--ExcTextField--placeholder: var(--color-border-outline-variant);
|
||||
|
||||
.ExcTextField {
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 50%; // 50% is not exactly in the center of the input
|
||||
transform: translateY(-50%);
|
||||
left: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-gray-40);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&--fullWidth {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
@@ -37,7 +50,6 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
|
||||
height: 3rem;
|
||||
|
||||
@@ -45,6 +57,8 @@
|
||||
border: 1px solid var(--ExcTextField--border);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
padding: 0 0.75rem;
|
||||
|
||||
&:not(&--readonly) {
|
||||
&:hover {
|
||||
border-color: var(--ExcTextField--border-hover);
|
||||
@@ -80,10 +94,6 @@
|
||||
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--ExcTextField--placeholder);
|
||||
}
|
||||
|
||||
&:not(:focus) {
|
||||
&:hover {
|
||||
background-color: initial;
|
||||
@@ -105,5 +115,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--hasIcon .ExcTextField__input {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ type TextFieldProps = {
|
||||
fullWidth?: boolean;
|
||||
selectOnRender?: boolean;
|
||||
|
||||
icon?: React.ReactNode;
|
||||
label?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
isRedacted?: boolean;
|
||||
} & ({ value: string } | { defaultValue: string });
|
||||
@@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
selectOnRender,
|
||||
onKeyDown,
|
||||
isRedacted = false,
|
||||
icon,
|
||||
className,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectOnRender) {
|
||||
// focusing first is needed because vitest/jsdom
|
||||
innerRef.current?.focus();
|
||||
innerRef.current?.select();
|
||||
}
|
||||
}, [selectOnRender]);
|
||||
@@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("ExcTextField", {
|
||||
className={clsx("ExcTextField", className, {
|
||||
"ExcTextField--fullWidth": fullWidth,
|
||||
"ExcTextField--hasIcon": !!icon,
|
||||
})}
|
||||
onClick={() => {
|
||||
innerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="ExcTextField__label">{label}</div>
|
||||
{icon}
|
||||
{label && <div className="ExcTextField__label">{label}</div>}
|
||||
<div
|
||||
className={clsx("ExcTextField__input", {
|
||||
"ExcTextField__input--readonly": readonly,
|
||||
|
||||
@@ -203,6 +203,7 @@ const getRelevantAppStateProps = (
|
||||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
editingTextElement: appState.editingTextElement,
|
||||
searchMatches: appState.searchMatches,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
||||
@@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon(
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const upIcon = createIcon(
|
||||
<g>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 15l6 -6l6 6" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LoadIcon,
|
||||
MoonIcon,
|
||||
save,
|
||||
searchIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
usersIcon,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
actionLoadScene,
|
||||
actionSaveToActiveFile,
|
||||
actionShortcuts,
|
||||
actionToggleSearchMenu,
|
||||
actionToggleTheme,
|
||||
} from "../../actions";
|
||||
import clsx from "clsx";
|
||||
@@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
|
||||
import { THEME } from "../../constants";
|
||||
import type { Theme } from "../../element/types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
|
||||
import "./DefaultItems.scss";
|
||||
|
||||
export const LoadScene = () => {
|
||||
@@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
|
||||
};
|
||||
CommandPalette.displayName = "CommandPalette";
|
||||
|
||||
export const SearchMenu = (opts?: { className?: string }) => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={searchIcon}
|
||||
data-testid="search-menu-button"
|
||||
onSelect={() => {
|
||||
actionManager.executeAction(actionToggleSearchMenu);
|
||||
}}
|
||||
shortcut={getShortcutFromShortcutName("searchMenu")}
|
||||
aria-label={t("search.title")}
|
||||
className={opts?.className}
|
||||
>
|
||||
{t("search.title")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
SearchMenu.displayName = "SearchMenu";
|
||||
|
||||
export const Help = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ export const ENV = {
|
||||
export const CLASSES = {
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -376,6 +377,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
||||
};
|
||||
|
||||
export const LIBRARY_SIDEBAR_TAB = "library";
|
||||
export const CANVAS_SEARCH_TAB = "search";
|
||||
|
||||
export const DEFAULT_SIDEBAR = {
|
||||
name: "default",
|
||||
|
||||
@@ -124,7 +124,7 @@ body.excalidraw-cursor-resize * {
|
||||
// recommends surface color of #121212, 93% yields #111111 for #FFF
|
||||
|
||||
canvas {
|
||||
filter: var(--theme-filter);
|
||||
// filter: var(--theme-filter);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,9 +144,9 @@
|
||||
--border-radius-md: 0.375rem;
|
||||
--border-radius-lg: 0.5rem;
|
||||
|
||||
--color-surface-high: hsl(244, 100%, 97%);
|
||||
--color-surface-mid: hsl(240 25% 96%);
|
||||
--color-surface-low: hsl(240 25% 94%);
|
||||
--color-surface-high: #f1f0ff;
|
||||
--color-surface-mid: #f2f2f7;
|
||||
--color-surface-low: #ececf4;
|
||||
--color-surface-lowest: #ffffff;
|
||||
--color-on-surface: #1b1b1f;
|
||||
--color-brand-hover: #5753d0;
|
||||
|
||||
@@ -189,7 +189,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||
$theme-filter: "invert(93%) hue-rotate(180deg)"; // prod
|
||||
// $theme-filter: "invert(93%)"; // prod
|
||||
// $theme-filter: "hue-rotate(180deg)"; // prod
|
||||
// $theme-filter: "hue-rotate(180deg) invert(93%)";
|
||||
|
||||
$right-sidebar-width: "302px";
|
||||
|
||||
:export {
|
||||
|
||||
@@ -57,6 +57,15 @@ export const base64ToString = async (base64: string, isByteString = false) => {
|
||||
: byteStringToString(window.atob(base64));
|
||||
};
|
||||
|
||||
export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
// Node.js environment
|
||||
return Buffer.from(base64, "base64").buffer;
|
||||
}
|
||||
// Browser environment
|
||||
return byteStringToArrayBuffer(atob(base64));
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// text encoding
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -223,7 +223,6 @@ export const newTextElement = (
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import type { AppState, Zoom } from "../types";
|
||||
import type { AppState, Offsets, Zoom } from "../types";
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import { viewportCoordsToSceneCoords } from "../utils";
|
||||
|
||||
@@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
|
||||
scrollY: number;
|
||||
},
|
||||
elementsMap: ElementsMap,
|
||||
padding?: Partial<{
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}>,
|
||||
padding?: Offsets,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
|
||||
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||
|
||||
@@ -284,16 +284,17 @@ export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
text = text
|
||||
const _text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(text, fontSize, lineHeight);
|
||||
const width = getTextWidth(text, font);
|
||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||
const width = getTextWidth(_text, font, forceAdvanceWidth);
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
|
||||
import { CLASSES, isSafari, POINTER_BUTTON, THEME } from "../constants";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
originalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { applyDarkModeFilter } from "../colors";
|
||||
|
||||
const getTransform = (
|
||||
width: number,
|
||||
@@ -247,7 +248,7 @@ export const textWysiwyg = ({
|
||||
|
||||
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
|
||||
const padding = !isSafari
|
||||
? Math.ceil(updatedTextElement.fontSize / 2)
|
||||
? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
|
||||
: 0;
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
@@ -273,10 +274,15 @@ export const textWysiwyg = ({
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
color: updatedTextElement.strokeColor,
|
||||
// color:
|
||||
// appState.theme === THEME.DARK
|
||||
// ? applyDarkModeFilter(updatedTextElement.strokeColor)
|
||||
// : updatedTextElement.strokeColor,
|
||||
opacity: updatedTextElement.opacity / 100,
|
||||
filter: "var(--theme-filter)",
|
||||
maxHeight: `${editorMaxHeight}px`,
|
||||
});
|
||||
// console.log("...", updatedTextElement.strokeColor);
|
||||
editable.scrollTop = 0;
|
||||
// For some reason updating font attribute doesn't set font family
|
||||
// hence updating font family explicitly for test environment
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { stringToBase64, toByteString } from "../data/encode";
|
||||
import {
|
||||
base64ToArrayBuffer,
|
||||
stringToBase64,
|
||||
toByteString,
|
||||
} from "../data/encode";
|
||||
import { LOCAL_FONT_PROTOCOL } from "./metadata";
|
||||
import loadWoff2 from "./wasm/woff2.loader";
|
||||
import loadHbSubset from "./wasm/hb-subset.loader";
|
||||
@@ -49,10 +53,7 @@ export class ExcalidrawFont implements Font {
|
||||
|
||||
// it's dataurl (server), the font is inlined as base64, no need to fetch
|
||||
if (url.protocol === "data:") {
|
||||
const arrayBuffer = Buffer.from(
|
||||
url.toString().split(",")[1],
|
||||
"base64",
|
||||
).buffer;
|
||||
const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
|
||||
|
||||
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||
arrayBuffer,
|
||||
|
||||
Vendored
+10
@@ -104,3 +104,13 @@ declare namespace jest {
|
||||
toBeNonNaNNumber(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace tinycolor {
|
||||
interface Instance {
|
||||
_r: number;
|
||||
_g: number;
|
||||
_b: number;
|
||||
_a: number;
|
||||
_ok: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,13 @@
|
||||
"hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
|
||||
"hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
|
||||
},
|
||||
"search": {
|
||||
"title": "Find on canvas",
|
||||
"noMatch": "No matches found...",
|
||||
"singleResult": "result",
|
||||
"multipleResults": "results",
|
||||
"placeholder": "Find text on canvas..."
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Reset the canvas",
|
||||
"exportJSON": "Export to file",
|
||||
@@ -297,6 +304,7 @@
|
||||
"shapes": "Shapes"
|
||||
},
|
||||
"hints": {
|
||||
"dismissSearch": "Escape to dismiss search",
|
||||
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
|
||||
"linearElement": "Click to start multiple points, drag for single line",
|
||||
"arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
"@tldraw/vec": "1.7.1",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
"clsx": "1.1.1",
|
||||
@@ -84,6 +85,7 @@
|
||||
"pwacompat": "2.0.17",
|
||||
"roughjs": "4.6.4",
|
||||
"sass": "1.51.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tunnel-rat": "0.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { StaticCanvasAppState, AppState } from "../types";
|
||||
import type { StaticCanvasRenderConfig } from "../scene/types";
|
||||
|
||||
import { THEME, THEME_FILTER } from "../constants";
|
||||
import { applyDarkModeFilter } from "../colors";
|
||||
|
||||
export const fillCircle = (
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -50,7 +51,7 @@ export const bootstrapCanvas = ({
|
||||
context.scale(scale, scale);
|
||||
|
||||
if (isExporting && theme === THEME.DARK) {
|
||||
context.filter = THEME_FILTER;
|
||||
// context.filter = THEME_FILTER;
|
||||
}
|
||||
|
||||
// Paint background
|
||||
@@ -64,7 +65,10 @@ export const bootstrapCanvas = ({
|
||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
}
|
||||
context.save();
|
||||
context.fillStyle = viewBackgroundColor;
|
||||
context.fillStyle =
|
||||
theme === THEME.DARK
|
||||
? applyDarkModeFilter(viewBackgroundColor)
|
||||
: viewBackgroundColor;
|
||||
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
context.restore();
|
||||
} else {
|
||||
|
||||
@@ -30,8 +30,12 @@ import {
|
||||
shouldShowBoundingBox,
|
||||
} from "../element/transformHandles";
|
||||
import { arrayToMap, throttleRAF } from "../utils";
|
||||
import type { InteractiveCanvasAppState } from "../types";
|
||||
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
FRAME_STYLE,
|
||||
THEME,
|
||||
} from "../constants";
|
||||
import { type InteractiveCanvasAppState } from "../types";
|
||||
|
||||
import { renderSnaps } from "../renderer/renderSnaps";
|
||||
|
||||
@@ -952,9 +956,48 @@ const _renderInteractiveScene = ({
|
||||
context.restore();
|
||||
}
|
||||
|
||||
appState.searchMatches.forEach(({ id, focus, matchedLines }) => {
|
||||
const element = elementsMap.get(id);
|
||||
|
||||
if (element && isTextElement(element)) {
|
||||
const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
|
||||
context.save();
|
||||
if (appState.theme === THEME.LIGHT) {
|
||||
if (focus) {
|
||||
context.fillStyle = "rgba(255, 124, 0, 0.4)";
|
||||
} else {
|
||||
context.fillStyle = "rgba(255, 226, 0, 0.4)";
|
||||
}
|
||||
} else if (focus) {
|
||||
context.fillStyle = "rgba(229, 82, 0, 0.4)";
|
||||
} else {
|
||||
context.fillStyle = "rgba(99, 52, 0, 0.4)";
|
||||
}
|
||||
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
context.translate(cx, cy);
|
||||
context.rotate(element.angle);
|
||||
|
||||
matchedLines.forEach((matchedLine) => {
|
||||
context.fillRect(
|
||||
elementX1 + matchedLine.offsetX - cx,
|
||||
elementY1 + matchedLine.offsetY - cy,
|
||||
matchedLine.width,
|
||||
matchedLine.height,
|
||||
);
|
||||
});
|
||||
|
||||
context.restore();
|
||||
}
|
||||
});
|
||||
|
||||
renderSnaps(context, appState);
|
||||
|
||||
// Reset zoom
|
||||
context.restore();
|
||||
|
||||
renderRemoteCursors({
|
||||
|
||||
@@ -61,6 +61,7 @@ import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { getVerticalOffset } from "../fonts";
|
||||
import { isRightAngleRads } from "../../math";
|
||||
import { getCornerRadius } from "../shapes";
|
||||
import { applyDarkModeFilter } from "../colors";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
@@ -247,9 +248,9 @@ const generateElementCanvas = (
|
||||
const rc = rough.canvas(canvas);
|
||||
|
||||
// in dark theme, revert the image color filter
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = IMAGE_INVERT_FILTER;
|
||||
}
|
||||
// if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
// context.filter = IMAGE_INVERT_FILTER;
|
||||
// }
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
|
||||
@@ -403,7 +404,10 @@ const drawElementOnCanvas = (
|
||||
case "freedraw": {
|
||||
// Draw directly to canvas
|
||||
context.save();
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.fillStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
|
||||
const path = getFreeDrawPath2D(element) as Path2D;
|
||||
const fillShape = ShapeCache.get(element);
|
||||
@@ -412,7 +416,10 @@ const drawElementOnCanvas = (
|
||||
rc.draw(fillShape);
|
||||
}
|
||||
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.fillStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.fill(path);
|
||||
|
||||
context.restore();
|
||||
@@ -458,7 +465,10 @@ const drawElementOnCanvas = (
|
||||
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
||||
context.save();
|
||||
context.font = getFontString(element);
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.fillStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.textAlign = element.textAlign as CanvasTextAlign;
|
||||
|
||||
// Canvas does not support multiline text by default
|
||||
@@ -699,7 +709,10 @@ export const renderElement = (
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||
context.strokeStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: FRAME_STYLE.strokeColor;
|
||||
|
||||
// TODO change later to only affect AI frames
|
||||
if (isMagicFrameElement(element)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
MIME_TYPES,
|
||||
SVG_NS,
|
||||
THEME,
|
||||
} from "../constants";
|
||||
import { normalizeLink, toValidURL } from "../data/url";
|
||||
import { getElementAbsoluteCoords } from "../element";
|
||||
@@ -37,6 +38,7 @@ import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
|
||||
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
|
||||
import { getVerticalOffset } from "../fonts";
|
||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||
import { applyDarkModeFilter } from "../colors";
|
||||
|
||||
const roughSVGDrawWithPrecision = (
|
||||
rsvg: RoughSVG,
|
||||
@@ -139,7 +141,7 @@ const renderElementToSvg = (
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
const shape = ShapeCache.generateElementShape(element, null);
|
||||
const shape = ShapeCache.generateElementShape(element, renderConfig);
|
||||
const node = roughSVGDrawWithPrecision(
|
||||
rsvg,
|
||||
shape,
|
||||
@@ -389,7 +391,12 @@ const renderElementToSvg = (
|
||||
);
|
||||
node.setAttribute("stroke", "none");
|
||||
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("fill", element.strokeColor);
|
||||
path.setAttribute(
|
||||
"fill",
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
);
|
||||
path.setAttribute("d", getFreeDrawSvgPath(element));
|
||||
node.appendChild(path);
|
||||
|
||||
@@ -421,6 +428,7 @@ const renderElementToSvg = (
|
||||
image.setAttribute("width", "100%");
|
||||
image.setAttribute("height", "100%");
|
||||
image.setAttribute("href", fileData.dataURL);
|
||||
image.setAttribute("preserveAspectRatio", "none");
|
||||
|
||||
symbol.appendChild(image);
|
||||
|
||||
@@ -525,7 +533,12 @@ const renderElementToSvg = (
|
||||
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
|
||||
|
||||
rect.setAttribute("fill", "none");
|
||||
rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
|
||||
rect.setAttribute(
|
||||
"stroke",
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
||||
: FRAME_STYLE.strokeColor,
|
||||
);
|
||||
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
|
||||
|
||||
addToRoot(rect, element);
|
||||
@@ -576,7 +589,12 @@ const renderElementToSvg = (
|
||||
text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`);
|
||||
text.setAttribute("font-family", getFontFamilyString(element));
|
||||
text.setAttribute("font-size", `${element.fontSize}px`);
|
||||
text.setAttribute("fill", element.strokeColor);
|
||||
text.setAttribute(
|
||||
"fill",
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
);
|
||||
text.setAttribute("text-anchor", textAnchor);
|
||||
text.setAttribute("style", "white-space: pre;");
|
||||
text.setAttribute("direction", direction);
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
import { generateFreeDrawShape } from "../renderer/renderElement";
|
||||
import { isTransparent, assertNever } from "../utils";
|
||||
import { simplify } from "points-on-curve";
|
||||
import { ROUGHNESS } from "../constants";
|
||||
import { ROUGHNESS, THEME } from "../constants";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isEmbeddableElement,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import type { EmbedsValidationStatus } from "../types";
|
||||
import type { AppState, EmbedsValidationStatus } from "../types";
|
||||
import {
|
||||
point,
|
||||
pointDistance,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type LocalPoint,
|
||||
} from "../../math";
|
||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||
import { applyDarkModeFilter } from "../colors";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
@@ -61,6 +62,7 @@ function adjustRoughness(element: ExcalidrawElement): number {
|
||||
export const generateRoughOptions = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
isDarkMode: boolean = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
@@ -85,7 +87,9 @@ export const generateRoughOptions = (
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: adjustRoughness(element),
|
||||
stroke: element.strokeColor,
|
||||
stroke: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
preserveVertices:
|
||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||
};
|
||||
@@ -99,6 +103,8 @@ export const generateRoughOptions = (
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill = isTransparent(element.backgroundColor)
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
if (element.type === "ellipse") {
|
||||
options.curveFitting = 1;
|
||||
@@ -112,6 +118,8 @@ export const generateRoughOptions = (
|
||||
options.fill =
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
}
|
||||
return options;
|
||||
@@ -165,6 +173,7 @@ const getArrowheadShapes = (
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
canvasBackgroundColor: string,
|
||||
isDarkMode: boolean,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
@@ -192,10 +201,14 @@ const getArrowheadShapes = (
|
||||
fill:
|
||||
arrowhead === "circle_outline"
|
||||
? canvasBackgroundColor
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
|
||||
fillStyle: "solid",
|
||||
stroke: element.strokeColor,
|
||||
stroke: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
}),
|
||||
];
|
||||
@@ -220,6 +233,8 @@ const getArrowheadShapes = (
|
||||
fill:
|
||||
arrowhead === "triangle_outline"
|
||||
? canvasBackgroundColor
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
@@ -248,6 +263,8 @@ const getArrowheadShapes = (
|
||||
fill:
|
||||
arrowhead === "diamond_outline"
|
||||
? canvasBackgroundColor
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
@@ -291,12 +308,15 @@ export const _generateElementShape = (
|
||||
isExporting,
|
||||
canvasBackgroundColor,
|
||||
embedsValidationStatus,
|
||||
theme,
|
||||
}: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: string;
|
||||
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||
theme: AppState["theme"];
|
||||
},
|
||||
): Drawable | Drawable[] | null => {
|
||||
const isDarkMode = theme === THEME.DARK;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
@@ -322,6 +342,7 @@ export const _generateElementShape = (
|
||||
embedsValidationStatus,
|
||||
),
|
||||
true,
|
||||
isDarkMode,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -337,6 +358,7 @@ export const _generateElementShape = (
|
||||
embedsValidationStatus,
|
||||
),
|
||||
false,
|
||||
isDarkMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -374,7 +396,7 @@ export const _generateElementShape = (
|
||||
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
||||
topY + horizontalRadius
|
||||
}`,
|
||||
generateRoughOptions(element, true),
|
||||
generateRoughOptions(element, true, isDarkMode),
|
||||
);
|
||||
} else {
|
||||
shape = generator.polygon(
|
||||
@@ -384,7 +406,7 @@ export const _generateElementShape = (
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
generateRoughOptions(element),
|
||||
generateRoughOptions(element, undefined, isDarkMode),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
@@ -395,14 +417,14 @@ export const _generateElementShape = (
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(element),
|
||||
generateRoughOptions(element, undefined, isDarkMode),
|
||||
);
|
||||
return shape;
|
||||
}
|
||||
case "line":
|
||||
case "arrow": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
const options = generateRoughOptions(element);
|
||||
const options = generateRoughOptions(element, undefined, isDarkMode);
|
||||
|
||||
// points array can be empty in the beginning, so it is important to add
|
||||
// initial position to it
|
||||
@@ -414,7 +436,7 @@ export const _generateElementShape = (
|
||||
shape = [
|
||||
generator.path(
|
||||
generateElbowArrowShape(points, 16),
|
||||
generateRoughOptions(element, true),
|
||||
generateRoughOptions(element, true, isDarkMode),
|
||||
),
|
||||
];
|
||||
} else if (!element.roundness) {
|
||||
@@ -446,6 +468,7 @@ export const _generateElementShape = (
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
@@ -463,6 +486,7 @@ export const _generateElementShape = (
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
@@ -477,7 +501,7 @@ export const _generateElementShape = (
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(element.points, 0.75);
|
||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
...generateRoughOptions(element, undefined, isDarkMode),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { _generateElementShape } from "./Shape";
|
||||
import type { ElementShape, ElementShapes } from "./types";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import type { AppState, EmbedsValidationStatus } from "../types";
|
||||
import { THEME } from "..";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
@@ -52,6 +53,7 @@ export class ShapeCache {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
theme: AppState["theme"];
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
@@ -74,6 +76,7 @@ export class ShapeCache {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
theme: THEME.LIGHT,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
|
||||
@@ -40,6 +40,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { renderStaticScene } from "../renderer/staticScene";
|
||||
import { Fonts } from "../fonts";
|
||||
import type { Font } from "../fonts/ExcalidrawFont";
|
||||
import { applyDarkModeFilter } from "../colors";
|
||||
|
||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
@@ -214,6 +215,8 @@ export const exportToCanvas = async (
|
||||
files,
|
||||
});
|
||||
|
||||
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
|
||||
|
||||
renderStaticScene({
|
||||
canvas,
|
||||
rc: rough.canvas(canvas),
|
||||
@@ -233,7 +236,7 @@ export const exportToCanvas = async (
|
||||
scrollY: -minY + exportPadding,
|
||||
zoom: defaultAppState.zoom,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
|
||||
theme,
|
||||
},
|
||||
renderConfig: {
|
||||
canvasBackgroundColor: viewBackgroundColor,
|
||||
@@ -244,6 +247,7 @@ export const exportToCanvas = async (
|
||||
embedsValidationStatus: new Map(),
|
||||
elementsPendingErasure: new Set(),
|
||||
pendingFlowchartNodes: null,
|
||||
theme,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -330,7 +334,7 @@ export const exportToSvg = async (
|
||||
svgRoot.setAttribute("width", `${width * exportScale}`);
|
||||
svgRoot.setAttribute("height", `${height * exportScale}`);
|
||||
if (exportWithDarkMode) {
|
||||
svgRoot.setAttribute("filter", THEME_FILTER);
|
||||
// svgRoot.setAttribute("filter", THEME_FILTER);
|
||||
}
|
||||
|
||||
const offsetX = -minX + exportPadding;
|
||||
@@ -376,7 +380,12 @@ export const exportToSvg = async (
|
||||
rect.setAttribute("y", "0");
|
||||
rect.setAttribute("width", `${width}`);
|
||||
rect.setAttribute("height", `${height}`);
|
||||
rect.setAttribute("fill", viewBackgroundColor);
|
||||
rect.setAttribute(
|
||||
"fill",
|
||||
appState.exportWithDarkMode
|
||||
? applyDarkModeFilter(viewBackgroundColor)
|
||||
: viewBackgroundColor,
|
||||
);
|
||||
svgRoot.appendChild(rect);
|
||||
}
|
||||
|
||||
@@ -384,6 +393,8 @@ export const exportToSvg = async (
|
||||
|
||||
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
||||
|
||||
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
|
||||
|
||||
renderSceneToSvg(
|
||||
elementsForRender,
|
||||
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
|
||||
@@ -405,6 +416,7 @@ export const exportToSvg = async (
|
||||
.map((element) => [element.id, true]),
|
||||
)
|
||||
: new Map(),
|
||||
theme,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppState, PointerCoords, Zoom } from "../types";
|
||||
import type { AppState, Offsets, PointerCoords, Zoom } from "../types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
getCommonBounds,
|
||||
@@ -31,14 +31,28 @@ export const centerScrollOn = ({
|
||||
scenePoint,
|
||||
viewportDimensions,
|
||||
zoom,
|
||||
offsets,
|
||||
}: {
|
||||
scenePoint: PointerCoords;
|
||||
viewportDimensions: { height: number; width: number };
|
||||
zoom: Zoom;
|
||||
offsets?: Offsets;
|
||||
}) => {
|
||||
let scrollX =
|
||||
(viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
|
||||
scenePoint.x;
|
||||
|
||||
scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
|
||||
|
||||
let scrollY =
|
||||
(viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
|
||||
scenePoint.y;
|
||||
|
||||
scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
|
||||
|
||||
return {
|
||||
scrollX: viewportDimensions.width / 2 / zoom.value - scenePoint.x,
|
||||
scrollY: viewportDimensions.height / 2 / zoom.value - scenePoint.y,
|
||||
scrollX,
|
||||
scrollY,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export type StaticCanvasRenderConfig = {
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
elementsPendingErasure: ElementsPendingErasure;
|
||||
pendingFlowchartNodes: PendingExcalidrawElements | null;
|
||||
theme: AppState["theme"];
|
||||
};
|
||||
|
||||
export type SVGRenderConfig = {
|
||||
@@ -46,6 +47,7 @@ export type SVGRenderConfig = {
|
||||
frameRendering: AppState["frameRendering"];
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
theme: AppState["theme"];
|
||||
};
|
||||
|
||||
export type InteractiveCanvasRenderConfig = {
|
||||
|
||||
@@ -866,6 +866,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -1068,6 +1069,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -1283,6 +1285,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -1613,6 +1616,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -1943,6 +1947,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -2158,6 +2163,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -2397,6 +2403,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0_copy": true,
|
||||
},
|
||||
@@ -2699,6 +2706,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -3065,6 +3073,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -3539,6 +3548,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -3861,6 +3871,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -4185,6 +4196,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -5370,6 +5382,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -6496,6 +6509,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -7431,6 +7445,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -8339,6 +8354,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -9235,6 +9251,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
|
||||
@@ -239,6 +239,55 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
Ctrl+Shift+E
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Find on canvas"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="search-menu-button"
|
||||
title="Find on canvas"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"
|
||||
/>
|
||||
<path
|
||||
d="M21 21l-6 -6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Find on canvas
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+F
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -80,6 +80,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id166": true,
|
||||
},
|
||||
@@ -681,6 +682,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id161": true,
|
||||
},
|
||||
@@ -1187,6 +1189,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -1554,6 +1557,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -1922,6 +1926,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -2185,6 +2190,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id180": true,
|
||||
},
|
||||
@@ -2627,6 +2633,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -2925,6 +2932,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -3208,6 +3216,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -3501,6 +3510,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -3786,6 +3796,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -4020,6 +4031,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -4278,6 +4290,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -4550,6 +4563,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -4780,6 +4794,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -5010,6 +5025,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -5238,6 +5254,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -5466,6 +5483,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -5723,6 +5741,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -6053,6 +6072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -6477,6 +6497,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id100": true,
|
||||
"id101": true,
|
||||
@@ -6855,6 +6876,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id105": true,
|
||||
"id106": true,
|
||||
@@ -7170,6 +7192,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id113": true,
|
||||
},
|
||||
@@ -7467,6 +7490,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -7695,6 +7719,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -8049,6 +8074,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -8406,6 +8432,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id127": true,
|
||||
"id128": true,
|
||||
@@ -8806,6 +8833,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -9092,6 +9120,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id124": true,
|
||||
},
|
||||
@@ -9356,6 +9385,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id81": true,
|
||||
},
|
||||
@@ -9619,6 +9649,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id83": true,
|
||||
},
|
||||
@@ -9852,6 +9883,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -10149,6 +10181,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id92": true,
|
||||
},
|
||||
@@ -10488,6 +10521,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -10725,6 +10759,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -11174,6 +11209,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id84": true,
|
||||
},
|
||||
@@ -11427,6 +11463,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -11665,6 +11702,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id14": true,
|
||||
},
|
||||
@@ -11905,6 +11943,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -12308,6 +12347,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
|
||||
"resizingElement": null,
|
||||
"scrollX": -50,
|
||||
"scrollY": -50,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -12551,6 +12591,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id78": true,
|
||||
},
|
||||
@@ -12791,6 +12832,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id19": true,
|
||||
},
|
||||
@@ -13033,6 +13075,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -13277,6 +13320,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id8": true,
|
||||
},
|
||||
@@ -13611,6 +13655,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -13779,6 +13824,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
"id2": true,
|
||||
@@ -14069,6 +14115,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -14334,6 +14381,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id11": true,
|
||||
},
|
||||
@@ -14609,6 +14657,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -14768,6 +14817,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id55": true,
|
||||
},
|
||||
@@ -15463,6 +15513,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id49": true,
|
||||
},
|
||||
@@ -16082,6 +16133,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id61": true,
|
||||
},
|
||||
@@ -16699,6 +16751,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id62": true,
|
||||
},
|
||||
@@ -17412,6 +17465,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id69": true,
|
||||
"id71": true,
|
||||
@@ -18161,6 +18215,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id39": true,
|
||||
"id41": true,
|
||||
@@ -18634,6 +18689,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id35_copy_copy": true,
|
||||
"id36_copy_copy": true,
|
||||
@@ -19155,6 +19211,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -19610,6 +19667,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
|
||||
"resizingElement": null,
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id27": true,
|
||||
},
|
||||
|
||||
@@ -84,6 +84,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id2": true,
|
||||
@@ -495,6 +496,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -893,6 +895,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id7": true,
|
||||
},
|
||||
@@ -1434,6 +1437,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -1636,6 +1640,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id2": true,
|
||||
@@ -2007,6 +2012,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -2241,6 +2247,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -2419,6 +2426,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -2733,6 +2741,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -2977,6 +2986,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -3216,6 +3226,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -3442,6 +3453,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -3694,6 +3706,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -3999,6 +4012,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -4412,6 +4426,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -4691,6 +4706,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -4939,6 +4955,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -5145,6 +5162,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -5338,6 +5356,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id2": true,
|
||||
},
|
||||
@@ -5719,6 +5738,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -6002,6 +6022,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -6809,6 +6830,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -7134,6 +7156,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -7406,6 +7429,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -7636,6 +7660,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -7867,6 +7892,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -8043,6 +8069,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -8219,6 +8246,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -8395,6 +8423,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -8613,6 +8642,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -8830,6 +8860,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -9020,6 +9051,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -9238,6 +9270,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -9414,6 +9447,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -9631,6 +9665,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -9807,6 +9842,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -9997,6 +10033,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -10177,6 +10214,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -10685,6 +10723,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -10956,6 +10995,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"scrollX": "-6.25000",
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -11080,6 +11120,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -11276,6 +11317,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -11584,6 +11626,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -11993,6 +12036,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@@ -12600,6 +12644,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"scrollX": 60,
|
||||
"scrollY": 60,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -12724,6 +12769,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -13305,6 +13351,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -13638,6 +13685,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@@ -13897,6 +13945,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"scrollX": 20,
|
||||
"scrollY": "-18.53553",
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -14019,6 +14068,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@@ -14394,6 +14444,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@@ -14519,6 +14570,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
|
||||
@@ -23,7 +23,6 @@ import { Excalidraw } from "../index";
|
||||
import type { NormalizedZoomValue } from "../types";
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { vi } from "vitest";
|
||||
import * as blob from "../data/blob";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElementPosition } from "../element/textElement";
|
||||
import { createPasteEvent } from "../clipboard";
|
||||
@@ -33,15 +32,15 @@ import { point, type Radians } from "../../math";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
// This needs to fixed in vitest mock, as when importActual used with mock
|
||||
// the tests hangs - https://github.com/vitest-dev/vitest/issues/546.
|
||||
// But fortunately spying and mocking the return value of spy works :p
|
||||
|
||||
const resizeImageFileSpy = vi.spyOn(blob, "resizeImageFile");
|
||||
const generateIdFromFileSpy = vi.spyOn(blob, "generateIdFromFile");
|
||||
|
||||
resizeImageFileSpy.mockImplementation(async (imageFile: File) => imageFile);
|
||||
generateIdFromFileSpy.mockImplementation(async () => "fileId" as FileId);
|
||||
vi.mock("../data/blob", async (actual) => {
|
||||
const orig: Object = await actual();
|
||||
return {
|
||||
...orig,
|
||||
resizeImageFile: (imageFile: File) => imageFile,
|
||||
generateIdFromFile: () => "fileId" as FileId,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
|
||||
@@ -76,11 +76,12 @@ export class API {
|
||||
});
|
||||
};
|
||||
|
||||
static updateElement = (
|
||||
...[element, updates]: Parameters<typeof mutateElement>
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
static updateElement = <T extends ExcalidrawElement>(
|
||||
...args: Parameters<typeof mutateElement<T>>
|
||||
) => {
|
||||
act(() => {
|
||||
mutateElement(element, updates);
|
||||
mutateElement<T>(...args);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -69,8 +69,11 @@ export class Keyboard {
|
||||
}
|
||||
};
|
||||
|
||||
static keyDown = (key: string) => {
|
||||
fireEvent.keyDown(document, {
|
||||
static keyDown = (
|
||||
key: string,
|
||||
target: HTMLElement | Document | Window = document,
|
||||
) => {
|
||||
fireEvent.keyDown(target, {
|
||||
key,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
@@ -78,8 +81,11 @@ export class Keyboard {
|
||||
});
|
||||
};
|
||||
|
||||
static keyUp = (key: string) => {
|
||||
fireEvent.keyUp(document, {
|
||||
static keyUp = (
|
||||
key: string,
|
||||
target: HTMLElement | Document | Window = document,
|
||||
) => {
|
||||
fireEvent.keyUp(target, {
|
||||
key,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
@@ -87,9 +93,9 @@ export class Keyboard {
|
||||
});
|
||||
};
|
||||
|
||||
static keyPress = (key: string) => {
|
||||
Keyboard.keyDown(key);
|
||||
Keyboard.keyUp(key);
|
||||
static keyPress = (key: string, target?: HTMLElement | Document | Window) => {
|
||||
Keyboard.keyDown(key, target);
|
||||
Keyboard.keyUp(key, target);
|
||||
};
|
||||
|
||||
static codeDown = (code: string) => {
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("event callbacks", () => {
|
||||
// files
|
||||
{},
|
||||
);
|
||||
expect(onChange.mock.lastCall[1].viewBackgroundColor).not.toBe(
|
||||
expect(onChange.mock?.lastCall?.[1].viewBackgroundColor).not.toBe(
|
||||
origBackgroundColor,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export const getTextEditor = async (selector: string, waitForEditor = true) => {
|
||||
};
|
||||
|
||||
export const updateTextEditor = (
|
||||
editor: HTMLTextAreaElement,
|
||||
editor: HTMLTextAreaElement | HTMLInputElement,
|
||||
value: string,
|
||||
) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import { act, render, waitFor } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import { KEYS } from "../keys";
|
||||
import { updateTextEditor } from "./queries/dom";
|
||||
import { API } from "./helpers/api";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const querySearchInput = async () => {
|
||||
const input =
|
||||
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||
)!;
|
||||
await waitFor(() => expect(input).not.toBeNull());
|
||||
return input;
|
||||
};
|
||||
|
||||
describe("search", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally />);
|
||||
API.setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle search on cmd+f", async () => {
|
||||
expect(h.app.state.openSidebar).toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
|
||||
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
|
||||
|
||||
const searchInput = await querySearchInput();
|
||||
expect(searchInput.matches(":focus")).toBe(true);
|
||||
});
|
||||
|
||||
it("should refocus search input with cmd+f when search sidebar is still open", async () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
|
||||
const searchInput =
|
||||
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
searchInput?.blur();
|
||||
});
|
||||
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(searchInput?.matches(":focus")).toBe(false);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(searchInput?.matches(":focus")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match text and cycle through matches on Enter", async () => {
|
||||
const scrollIntoViewMock = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
API.setElements([
|
||||
API.createElement({ type: "text", text: "test one" }),
|
||||
API.createElement({ type: "text", text: "test two" }),
|
||||
]);
|
||||
|
||||
expect(h.app.state.openSidebar).toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
|
||||
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
|
||||
|
||||
const searchInput = await querySearchInput();
|
||||
|
||||
expect(searchInput.matches(":focus")).toBe(true);
|
||||
|
||||
updateTextEditor(searchInput, "test");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.app.state.searchMatches.length).toBe(2);
|
||||
expect(h.app.state.searchMatches[0].focus).toBe(true);
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER, searchInput);
|
||||
expect(h.app.state.searchMatches[0].focus).toBe(false);
|
||||
expect(h.app.state.searchMatches[1].focus).toBe(true);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER, searchInput);
|
||||
expect(h.app.state.searchMatches[0].focus).toBe(true);
|
||||
expect(h.app.state.searchMatches[1].focus).toBe(false);
|
||||
});
|
||||
|
||||
it("should match text split across multiple lines", async () => {
|
||||
const scrollIntoViewMock = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "text",
|
||||
text: "",
|
||||
}),
|
||||
]);
|
||||
|
||||
API.updateElement(h.elements[0] as ExcalidrawTextElement, {
|
||||
text: "t\ne\ns\nt \nt\ne\nx\nt \ns\np\nli\nt \ni\nn\nt\no\nm\nu\nlt\ni\np\nl\ne \nli\nn\ne\ns",
|
||||
originalText: "test text split into multiple lines",
|
||||
});
|
||||
|
||||
expect(h.app.state.openSidebar).toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.F);
|
||||
});
|
||||
expect(h.app.state.openSidebar).not.toBeNull();
|
||||
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
|
||||
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
|
||||
|
||||
const searchInput = await querySearchInput();
|
||||
|
||||
expect(searchInput.matches(":focus")).toBe(true);
|
||||
|
||||
updateTextEditor(searchInput, "test");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.app.state.searchMatches.length).toBe(1);
|
||||
expect(h.app.state.searchMatches[0]?.matchedLines?.length).toBe(4);
|
||||
});
|
||||
|
||||
updateTextEditor(searchInput, "ext spli");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.app.state.searchMatches.length).toBe(1);
|
||||
expect(h.app.state.searchMatches[0]?.matchedLines?.length).toBe(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -198,6 +198,8 @@ export type InteractiveCanvasAppState = Readonly<
|
||||
snapLines: AppState["snapLines"];
|
||||
zenModeEnabled: AppState["zenModeEnabled"];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
// Search matches
|
||||
searchMatches: AppState["searchMatches"];
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -384,8 +386,20 @@ export interface AppState {
|
||||
userToFollow: UserToFollow | null;
|
||||
/** the socket ids of the users following the current user */
|
||||
followedBy: Set<SocketId>;
|
||||
searchMatches: readonly SearchMatch[];
|
||||
}
|
||||
|
||||
type SearchMatch = {
|
||||
id: string;
|
||||
focus: boolean;
|
||||
matchedLines: {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type UIAppState = Omit<
|
||||
AppState,
|
||||
| "suggestedBindings"
|
||||
@@ -642,6 +656,9 @@ export type AppClassProperties = {
|
||||
getEffectiveGridSize: App["getEffectiveGridSize"];
|
||||
setPlugins: App["setPlugins"];
|
||||
plugins: App["plugins"];
|
||||
getEditorUIOffsets: App["getEditorUIOffsets"];
|
||||
visibleElements: App["visibleElements"];
|
||||
excalidrawContainerValue: App["excalidrawContainerValue"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
@@ -834,3 +851,10 @@ export type GenerateDiagramToCode = (props: {
|
||||
frame: ExcalidrawMagicFrameElement;
|
||||
children: readonly ExcalidrawElement[];
|
||||
}) => MaybePromise<{ html: string }>;
|
||||
|
||||
export type Offsets = Partial<{
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}>;
|
||||
|
||||
+18
-5
@@ -1,14 +1,27 @@
|
||||
export const PRECISION = 10e-5;
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
export const clamp = (value: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
};
|
||||
|
||||
export function round(value: number, precision: number) {
|
||||
export const round = (
|
||||
value: number,
|
||||
precision: number,
|
||||
func: "round" | "floor" | "ceil" = "round",
|
||||
) => {
|
||||
const multiplier = Math.pow(10, precision);
|
||||
|
||||
return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
|
||||
}
|
||||
return Math[func]((value + Number.EPSILON) * multiplier) / multiplier;
|
||||
};
|
||||
|
||||
export const roundToStep = (
|
||||
value: number,
|
||||
step: number,
|
||||
func: "round" | "floor" | "ceil" = "round",
|
||||
): number => {
|
||||
const factor = 1 / step;
|
||||
return Math[func](value * factor) / factor;
|
||||
};
|
||||
|
||||
export const average = (a: number, b: number) => (a + b) / 2;
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
|
||||
@@ -32,7 +32,6 @@ describe("exportToCanvas", async () => {
|
||||
|
||||
describe("exportToBlob", async () => {
|
||||
describe("mime type", () => {
|
||||
// afterEach(vi.restoreAllMocks);
|
||||
it("should change image/jpg to image/jpeg", async () => {
|
||||
const blob = await utils.exportToBlob({
|
||||
...diagramFactory(),
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ vi.mock(
|
||||
const url = this.urls[0];
|
||||
|
||||
if (url.protocol !== "file:") {
|
||||
return super.getContent();
|
||||
return super.getContent(new Set());
|
||||
}
|
||||
|
||||
// read local assets directly, without running a server
|
||||
|
||||
+12
-4
@@ -2,19 +2,27 @@ import { defineConfig } from "vitest/config";
|
||||
import { woff2BrowserPlugin } from "./scripts/woff2/woff2-vite-plugins";
|
||||
|
||||
export default defineConfig({
|
||||
// @ts-ignore
|
||||
//@ts-ignore
|
||||
plugins: [woff2BrowserPlugin()],
|
||||
test: {
|
||||
// Since hooks are running in stack in v2, which means all hooks run serially whereas
|
||||
// we need to run them in parallel
|
||||
sequence: {
|
||||
hooks: "parallel",
|
||||
},
|
||||
setupFiles: ["./setupTests.ts"],
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
coverage: {
|
||||
reporter: ["text", "json-summary", "json", "html", "lcovonly"],
|
||||
// Since v2, it ignores empty lines by default and we need to disable it as it affects the coverage
|
||||
// Additionally the thresholds also needs to be updated slightly as a result of this change
|
||||
ignoreEmptyLines: false,
|
||||
thresholds: {
|
||||
lines: 70,
|
||||
lines: 66,
|
||||
branches: 70,
|
||||
functions: 68,
|
||||
statements: 70,
|
||||
functions: 63,
|
||||
statements: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user