Compare commits

..

2 Commits

Author SHA1 Message Date
Aakansha Doshi
555bf6338f fix test 2022-11-09 22:55:27 +05:30
Daniel J. Geiger
0bef3945f6 Fix #5855. 2022-11-09 07:54:49 -06:00
208 changed files with 10714 additions and 14342 deletions

View File

@@ -2,7 +2,7 @@ name: Auto release excalidraw next
on:
push:
branches:
- release
- master
jobs:
Auto-release-excalidraw-next:

View File

@@ -3,7 +3,7 @@ name: Build Docker image
on:
push:
branches:
- release
- master
jobs:
build-docker:

View File

@@ -3,7 +3,7 @@ name: Cancel previous runs
on:
push:
branches:
- release
- master
pull_request:
jobs:

View File

@@ -3,7 +3,7 @@ name: Publish Docker
on:
push:
branches:
- release
- master
jobs:
publish-docker:

View File

@@ -3,7 +3,7 @@ name: New Sentry production release
on:
push:
branches:
- release
- master
jobs:
sentry:

View File

@@ -4755,9 +4755,9 @@ loader-runner@^4.2.0:
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
loader-utils@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==
version "2.0.2"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129"
integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==
dependencies:
big.js "^5.2.2"
emojis-list "^3.0.0"

View File

@@ -28,9 +28,9 @@
"@types/pica": "5.1.3",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
@@ -50,23 +50,11 @@
"pwacompat": "2.0.17",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-scripts": "5.0.1",
"react-scripts": "4.0.3",
"roughjs": "4.5.2",
"sass": "1.51.0",
"socket.io-client": "4.5.4",
"typescript": "4.9.4",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4",
"workbox-google-analytics": "^6.5.4",
"workbox-navigation-preload": "^6.5.4",
"workbox-precaching": "^6.5.4",
"workbox-range-requests": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4",
"workbox-streams": "^6.5.4"
"socket.io-client": "2.3.1",
"typescript": "4.5.5"
},
"devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
@@ -79,7 +67,6 @@
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4",
"jest-canvas-mock": "2.4.0",
"lint-staged": "12.3.7",
@@ -102,10 +89,11 @@
"private": true,
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build",
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"build:prebuild": "node ./scripts/prebuild.js",
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
"eject": "react-scripts eject",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
@@ -115,7 +103,6 @@
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
"test:app": "react-scripts test --passWithNoTests",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",

View File

@@ -146,8 +146,7 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"

81
public/service-worker.js Normal file
View File

@@ -0,0 +1,81 @@
// eslint-disable-next-line no-restricted-globals
// eslint-disable-next-line no-unused-expressions
/* eslint-disable no-restricted-globals */
/* global importScripts, workbox */
/**
* Welcome to your Workbox-powered service worker!
*
* You'll need to register this file in your web app and you should
* disable HTTP caching for this file too.
* See https://goo.gl/nhQhGp
*
* The rest of the code is auto-generated. Please don't update this file
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
* See https://goo.gl/2aRDsh
*/
// in dev, `process` is undefined because this file is not compiled until build
const IS_DEVELOPMENT =
typeof process === "undefined" || process.env.NODE_ENV !== "production";
if (IS_DEVELOPMENT) {
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
);
workbox.setConfig({
debug: true,
});
} else {
importScripts("/workbox/workbox-sw.js");
workbox.setConfig({
modulePathPrefix: "/workbox/",
});
}
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
workbox.core.clientsClaim();
if (!IS_DEVELOPMENT) {
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
workbox.routing.registerNavigationRoute(
workbox.precaching.getCacheKeyForURL("./index.html"),
{
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
},
);
}
// Cache relevant font files
workbox.routing.registerRoute(
new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"),
new workbox.strategies.StaleWhileRevalidate({
cacheName: "fonts",
plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })],
}),
);
self.addEventListener("fetch", (event) => {
if (
event.request.method === "POST" &&
event.request.url.endsWith("/web-share-target")
) {
return event.respondWith(
(async () => {
const formData = await event.request.formData();
const file = formData.get("file");
const webShareTargetCache = await caches.open("web-share-target");
await webShareTargetCache.put("shared-file", new Response(file));
return Response.redirect("/?web-share-target", 303);
})(),
);
}
});

View File

@@ -50,8 +50,8 @@ const crowdinMap = {
"lv-LV": "en-lv",
"cs-CZ": "en-cs",
"kk-KZ": "en-kk",
"vi-VN": "en-vi",
"mr-IN": "en-mr",
"vi-vn": "en-vi",
"mr-in": "en-mr",
};
const flags = {
@@ -120,7 +120,6 @@ const languages = {
"fa-IR": "فارسی",
"fi-FI": "Suomi",
"fr-FR": "Français",
"gl-ES": "Galego",
"he-IL": "עברית",
"hi-IN": "हिन्दी",
"hu-HU": "Magyar",
@@ -130,7 +129,6 @@ const languages = {
"kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі",
"ko-KR": "한국어",
"ku-TR": "Kurdî",
"lt-LT": "Lietuvių",
"lv-LV": "Latviešu",
"my-MM": "Burmese",

21
scripts/prebuild.js Normal file
View File

@@ -0,0 +1,21 @@
const fs = require("fs");
const path = require("path");
// for development purposes we want to have the service-worker.js file
// accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => {
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
const newPath = path.resolve(__dirname, "../src/service-worker.js");
fs.rename(oldPath, newPath, (error) => {
if (error) {
throw error;
}
console.info("public/service-worker.js moved to src/");
});
};
// -----------------------------------------------------------------------------
moveServiceWorkerScript();

View File

@@ -6,10 +6,6 @@ import {
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import {
getOriginalContainerHeightFromCache,
resetOriginalContainerCache,
} from "../element/textWysiwyg";
import {
hasBoundTextElement,
isTextBindableContainer,
@@ -42,11 +38,6 @@ export const actionUnbindText = register({
boundTextElement.originalText,
getFontString(boundTextElement),
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
);
resetOriginalContainerCache(element.id);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
@@ -58,9 +49,6 @@ export const actionUnbindText = register({
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
height: originalContainerHeight
? originalContainerHeight
: element.height,
});
}
});

View File

@@ -23,7 +23,7 @@ import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState, isEraserActive } from "../appState";
import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx";
import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
import MenuItem from "../components/MenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeViewBackgroundColor = register({
@@ -90,7 +90,6 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({
name: "zoomIn",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
@@ -127,7 +126,6 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
@@ -164,7 +162,6 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
@@ -274,7 +271,6 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({
name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) =>
@@ -286,7 +282,6 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
return {
@@ -299,23 +294,19 @@ export const actionToggleTheme = register({
};
},
PanelComponent: ({ appState, updateData }) => (
<DropdownMenuItem
onSelect={() => {
<MenuItem
label={
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
onClick={() => {
updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
}}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
dataTestId="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")}
ariaLabel={
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
>
{appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")}
</DropdownMenuItem>
/>
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
});

View File

@@ -3,7 +3,6 @@ import { register } from "./register";
import {
copyTextToSystemClipboard,
copyToClipboard,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
@@ -24,31 +23,11 @@ export const actionCopy = register({
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register({
name: "cut",
trackEvent: { category: "element" },
@@ -56,9 +35,6 @@ export const actionCut = register({
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState);
},
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
});
@@ -101,9 +77,6 @@ export const actionCopyAsSvg = register({
};
}
},
contextItemPredicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
contextItemLabel: "labels.copyAsSvg",
});
@@ -158,9 +131,6 @@ export const actionCopyAsPng = register({
};
}
},
contextItemPredicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
});

View File

@@ -19,7 +19,7 @@ import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
import MenuItem from "../components/MenuItem";
import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeProjectName = register({
@@ -179,7 +179,6 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
viewMode: true,
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
try {
@@ -247,19 +246,15 @@ export const actionLoadScene = register({
}
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData }) => {
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={updateData}
dataTestId="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
ariaLabel={t("buttons.load")}
>
{t("buttons.load")}
</DropdownMenuItem>
);
},
PanelComponent: ({ updateData }) => (
<MenuItem
label={t("buttons.load")}
icon={LoadIcon}
onClick={updateData}
dataTestId="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
/>
),
});
export const actionExportWithDarkMode = register({

View File

@@ -14,7 +14,6 @@ import {
} from "../element/bounds";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { KEYS } from "../keys";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
@@ -64,8 +63,7 @@ export const actionFlipVertical = register({
commitToHistory: true,
};
},
keyTest: (event) =>
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
keyTest: (event) => event.shiftKey && event.code === "KeyV",
contextItemLabel: "labels.flipVertical",
contextItemPredicate: (elements, appState) =>
enableActionFlipVertical(elements, appState),
@@ -153,7 +151,11 @@ const flipElement = (
let initialPointsCoords;
if (isLinearElement(element)) {
initialPointsCoords = getElementPointsCoords(element, element.points);
initialPointsCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness,
);
}
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
@@ -211,7 +213,11 @@ const flipElement = (
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
// There's still room for improvement since when the line roughness is > 1
// we still have a small offset of the origin when fliipping the element.
const finalPointsCoords = getElementPointsCoords(element, element.points);
const finalPointsCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness,
);
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];

View File

@@ -6,7 +6,7 @@ import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys";
import { HelpButton } from "../components/HelpButton";
import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
import MenuItem from "../components/MenuItem";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@@ -56,7 +56,6 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({
name: "toggleFullScreen",
viewMode: true,
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => {
if (!isFullScreen()) {
@@ -74,7 +73,6 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog === "help") {
@@ -90,15 +88,13 @@ export const actionShortcuts = register({
},
PanelComponent: ({ updateData, isInHamburgerMenu }) =>
isInHamburgerMenu ? (
<DropdownMenuItem
<MenuItem
label={t("helpDialog.title")}
dataTestId="help-menu-item"
icon={HelpIcon}
onSelect={updateData}
onClick={updateData}
shortcut="?"
ariaLabel={t("helpDialog.title")}
>
{t("helpDialog.title")}
</DropdownMenuItem>
/>
) : (
<HelpButton title={t("helpDialog.title")} onClick={updateData} />
),

View File

@@ -6,7 +6,6 @@ import { register } from "./register";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];

View File

@@ -42,7 +42,6 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
VERTICAL_ALIGN,
} from "../constants";
import {
@@ -58,7 +57,7 @@ import {
import {
isBoundToContainer,
isLinearElement,
isUsingAdaptiveRadius,
isLinearElementType,
} from "../element/typeChecks";
import {
Arrowhead,
@@ -73,7 +72,7 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random";
import {
canChangeRoundness,
canChangeSharpness,
canHaveArrowheads,
getCommonAttributeOfSelectedElements,
getSelectedElements,
@@ -817,19 +816,16 @@ export const actionChangeVerticalAlign = register({
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
testId: "align-top",
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
testId: "align-middle",
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
testId: "align-bottom",
},
]}
value={getFormValue(elements, appState, (element) => {
@@ -849,71 +845,69 @@ export const actionChangeVerticalAlign = register({
},
});
export const actionChangeRoundness = register({
name: "changeRoundness",
export const actionChangeSharpness = register({
name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
roundness:
value === "round"
? {
type: isUsingAdaptiveRadius(el.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
}),
),
appState: {
...appState,
currentItemRoundness: value,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const hasLegacyRoundness = targetElements.some(
(el) => el.roundness?.type === ROUNDNESS.LEGACY,
);
return (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: EdgeSharpIcon,
},
{
value: "round",
text: t("labels.round"),
icon: EdgeRoundIcon,
},
]}
value={getFormValue(
elements,
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) &&
appState.currentItemRoundness) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.activeTool.type);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.activeTool.type);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeSharpness: value,
}),
),
appState: {
...appState,
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
? value
: appState.currentItemStrokeSharpness,
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
? value
: appState.currentItemLinearStrokeSharpness,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: EdgeSharpIcon,
},
{
value: "round",
text: t("labels.round"),
icon: EdgeRoundIcon,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.activeTool.type) &&
(isLinearElementType(appState.activeTool.type)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});
export const actionChangeArrowhead = register({

View File

@@ -13,11 +13,7 @@ import {
DEFAULT_TEXT_ALIGN,
} from "../constants";
import { getBoundTextElement } from "../element/textElement";
import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
} from "../element/typeChecks";
import { hasBoundTextElement } from "../element/typeChecks";
import { getSelectedElements } from "../scene";
// `copiedStyles` is exported only for tests.
@@ -81,14 +77,6 @@ export const actionPasteStyles = register({
fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness,
roundness: elementStylesToCopyFrom.roundness
? canApplyRoundnessTypeToElement(
elementStylesToCopyFrom.roundness.type,
element,
)
? elementStylesToCopyFrom.roundness
: getDefaultRoundnessTypeForElement(element)
: null,
});
if (isTextElement(newElement)) {

View File

@@ -5,7 +5,6 @@ import { AppState } from "../types";
export const actionToggleGridMode = register({
name: "gridMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
@@ -20,9 +19,6 @@ export const actionToggleGridMode = register({
};
},
checked: (appState: AppState) => appState.gridSize !== null,
contextItemPredicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@@ -41,9 +41,15 @@ export const actionToggleLock = register({
: "labels.elementLock.lock";
}
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
if (selected.length > 1) {
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
},
keyTest: (event, appState, elements) => {
return (

View File

@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({
name: "stats",
viewMode: true,
trackEvent: { category: "menu" },
perform(elements, appState) {
return {

View File

@@ -3,7 +3,6 @@ import { register } from "./register";
export const actionToggleViewMode = register({
name: "viewMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
@@ -18,9 +17,6 @@ export const actionToggleViewMode = register({
};
},
checked: (appState) => appState.viewModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,

View File

@@ -3,7 +3,6 @@ import { register } from "./register";
export const actionToggleZenMode = register({
name: "zenMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
@@ -18,9 +17,6 @@ export const actionToggleZenMode = register({
};
},
checked: (appState) => appState.zenModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,

View File

@@ -9,6 +9,7 @@ import {
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { MODES } from "../constants";
import { trackEvent } from "../analytics";
const trackAction = (
@@ -102,8 +103,11 @@ export class ActionManager {
const action = data[0];
if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
return false;
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
return false;
}
}
const elements = this.getElementsIncludingDeleted();

View File

@@ -48,7 +48,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
deleteSelectedElements: [getShortcutKey("Delete")],
deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),

View File

@@ -91,7 +91,7 @@ export type ActionName =
| "ungroup"
| "goToCollaborator"
| "addToLibrary"
| "changeRoundness"
| "changeSharpness"
| "alignTop"
| "alignBottom"
| "alignLeft"
@@ -143,8 +143,6 @@ export interface Action {
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent:
@@ -166,7 +164,4 @@ export interface Action {
value: any,
) => boolean;
};
/** if set to `true`, allow action to be performed in viewMode.
* Defaults to `false` */
viewMode?: boolean;
}

View File

@@ -28,11 +28,12 @@ export const getDefaultAppState = (): Omit<
currentItemFillStyle: "hachure",
currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemLinearStrokeSharpness: "round",
currentItemOpacity: 100,
currentItemRoughness: 1,
currentItemStartArrowhead: null,
currentItemStrokeColor: oc.black,
currentItemRoundness: "round",
currentItemStrokeSharpness: "sharp",
currentItemStrokeStyle: "solid",
currentItemStrokeWidth: 1,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
@@ -64,7 +65,6 @@ export const getDefaultAppState = (): Omit<
lastPointerDownWith: "mouse",
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
contextMenu: null,
openMenu: null,
openPopup: null,
openSidebar: null,
@@ -120,7 +120,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false },
currentItemRoundness: {
currentItemLinearStrokeSharpness: {
browser: true,
export: false,
server: false,
@@ -129,6 +129,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
@@ -158,7 +159,6 @@ const APP_STATE_STORAGE_CONF = (<
name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false },
contextMenu: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false },

View File

@@ -172,7 +172,7 @@ const commonProps = {
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0],
roundness: null,
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
@@ -322,7 +322,7 @@ const chartBaseElements = (
text: spreadsheet.title,
x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null,
strokeSharpness: "sharp",
strokeStyle: "solid",
textAlign: "center",
})

View File

@@ -1,27 +0,0 @@
import { parseClipboard } from "./clipboard";
describe("Test parseClipboard", () => {
it("should parse valid json correctly", async () => {
let text = "123";
let clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
text = "[123]";
clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
});
});

View File

@@ -109,16 +109,16 @@ const parsePotentialSpreadsheet = (
* Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported)
*/
export const getSystemClipboard = async (
const getSystemClipboard = async (
event: ClipboardEvent | null,
): Promise<string> => {
try {
const text = event
? event.clipboardData?.getData("text/plain")
? event.clipboardData?.getData("text/plain").trim()
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
return (text || "").trim();
return text || "";
} catch {
return "";
}
@@ -129,25 +129,19 @@ export const getSystemClipboard = async (
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
isPlainPaste = false,
): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements
if (
!systemClipboard ||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
) {
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
return getAppClipboard();
}
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) {
return spreadsheetResult;
}
@@ -160,23 +154,17 @@ export const parseClipboard = async (
return {
elements: systemClipboardData.elements,
files: systemClipboardData.files,
text: isPlainPaste
? JSON.stringify(systemClipboardData.elements, null, 2)
: undefined,
};
}
} catch (e) {}
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? {
...appClipboardData,
text: isPlainPaste
? JSON.stringify(appClipboardData.elements, null, 2)
: undefined,
}
: { text: systemClipboard };
return appClipboardData;
} catch {
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? appClipboardData
: { text: systemClipboard };
}
};
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {

View File

@@ -5,7 +5,7 @@ import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n";
import { useDevice } from "../components/App";
import {
canChangeRoundness,
canChangeSharpness,
canHaveArrowheads,
getTargetElements,
hasBackground,
@@ -25,12 +25,11 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip";
import { shouldAllowVerticalAlign } from "../element/textElement";
export const SelectedShapeActions = ({
appState,
@@ -110,9 +109,9 @@ export const SelectedShapeActions = ({
</>
)}
{(canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) => canChangeRoundness(element.type))) && (
<>{renderAction("changeRoundness")}</>
{(canChangeSharpness(appState.activeTool.type) ||
targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</>
)}
{(hasText(appState.activeTool.type) ||
@@ -126,8 +125,10 @@ export const SelectedShapeActions = ({
</>
)}
{shouldAllowVerticalAlign(targetElements) &&
renderAction("changeVerticalAlign")}
{targetElements.some(
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(appState.activeTool.type) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</>

View File

@@ -5,7 +5,7 @@ import { save } from "../components/icons";
import { t } from "../i18n";
import "./ActiveFile.scss";
import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
import MenuItem from "./MenuItem";
type ActiveFileProps = {
fileName?: string;
@@ -13,11 +13,11 @@ type ActiveFileProps = {
};
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<DropdownMenuItem
<MenuItem
label={`${t("buttons.save")}`}
shortcut={getShortcutFromShortcutName("saveScene")}
dataTestId="save-button"
onSelect={onSave}
onClick={onSave}
icon={save}
ariaLabel={`${t("buttons.save")}`}
>{`${t("buttons.save")}`}</DropdownMenuItem>
/>
);

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,8 @@
.Avatar {
width: 1.25rem;
height: 1.25rem;
position: relative;
border-radius: 100%;
outline: 2px solid var(--avatar-border-color);
outline-offset: 2px;
display: flex;
justify-content: center;
@@ -21,16 +21,5 @@
height: 100%;
border-radius: 100%;
}
&::before {
content: "";
position: absolute;
top: -3px;
right: -3px;
bottom: -3px;
left: -3px;
border: 1px solid var(--avatar-border-color);
border-radius: 100%;
}
}
}

View File

@@ -3,7 +3,7 @@ import { t } from "../i18n";
import { TrashIcon } from "./icons";
import ConfirmDialog from "./ConfirmDialog";
import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
import MenuItem from "./MenuItem";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false);
@@ -13,14 +13,12 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
return (
<>
<DropdownMenuItem
<MenuItem
label={t("buttons.clearReset")}
icon={TrashIcon}
onSelect={toggleDialog}
onClick={toggleDialog}
dataTestId="clear-canvas-button"
ariaLabel={t("buttons.clearReset")}
>
{t("buttons.clearReset")}
</DropdownMenuItem>
/>
{showDialog && (
<ConfirmDialog

View File

@@ -2,7 +2,7 @@ import { t } from "../i18n";
import { UsersIcon } from "./icons";
import "./CollabButton.scss";
import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
import MenuItem from "./MenuItem";
import clsx from "clsx";
const CollabButton = ({
@@ -19,14 +19,13 @@ const CollabButton = ({
return (
<>
{isInHamburgerMenu ? (
<DropdownMenuItem
<MenuItem
label={t("labels.liveCollaboration")}
dataTestId="collab-button"
icon={UsersIcon}
onSelect={onClick}
ariaLabel={t("labels.liveCollaboration")}
>
{t("labels.liveCollaboration")}
</DropdownMenuItem>
onClick={onClick}
isCollaborating={isCollaborating}
/>
) : (
<button
className={clsx("collab-button", { active: isCollaborating })}

View File

@@ -66,13 +66,10 @@ const getColor = (color: string): string | null => {
return color;
}
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
return isValidColor(color)
? color
: isValidColor(`#${color}`)
? `#${color}`
: null;
};

View File

@@ -3,9 +3,9 @@ import { Dialog, DialogProps } from "./Dialog";
import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton";
import { isMenuOpenAtom } from "./App";
import { isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { useExcalidrawSetAppState } from "./App";
interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void;
@@ -23,8 +23,9 @@ const ConfirmDialog = (props: Props) => {
className = "",
...rest
} = props;
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
return (
<Dialog
@@ -38,16 +39,16 @@ const ConfirmDialog = (props: Props) => {
<DialogActionButton
label={cancelText}
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
setIsMenuOpen(false);
setIsDropdownOpen(false);
onCancel();
}}
/>
<DialogActionButton
label={confirmText}
onClick={() => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
setIsMenuOpen(false);
setIsDropdownOpen(false);
onConfirm();
}}
actionType="danger"

View File

@@ -19,7 +19,7 @@
color: var(--popup-text-color);
}
.context-menu-item {
.context-menu-option {
position: relative;
width: 100%;
min-width: 9.5rem;
@@ -43,16 +43,16 @@
}
&.dangerous {
.context-menu-item__label {
.context-menu-option__label {
color: $oc-red-7;
}
}
.context-menu-item__label {
.context-menu-option__label {
justify-self: start;
margin-inline-end: 20px;
}
.context-menu-item__shortcut {
.context-menu-option__shortcut {
justify-self: end;
opacity: 0.6;
font-family: inherit;
@@ -60,37 +60,37 @@
}
}
.context-menu-item:hover {
.context-menu-option:hover {
color: var(--popup-bg-color);
background-color: var(--select-highlight-color);
&.dangerous {
.context-menu-item__label {
.context-menu-option__label {
color: var(--popup-bg-color);
}
background-color: $oc-red-6;
}
}
.context-menu-item:focus {
.context-menu-option:focus {
z-index: 1;
}
@include isMobile {
.context-menu-item {
.context-menu-option {
display: block;
.context-menu-item__label {
.context-menu-option__label {
margin-inline-end: 0;
}
.context-menu-item__shortcut {
.context-menu-option__shortcut {
display: none;
}
}
}
.context-menu-item-separator {
.context-menu-option-separator {
border: none;
border-top: 1px solid $oc-gray-5;
}

View File

@@ -1,3 +1,4 @@
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
import { t } from "../i18n";
@@ -9,116 +10,140 @@ import {
} from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import {
useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import React from "react";
import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
export type ContextMenuOption = "separator" | Action;
type ContextMenuProps = {
actionManager: ActionManager;
items: ContextMenuItems;
options: ContextMenuOption[];
onCloseRequest?(): void;
top: number;
left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
elements: readonly NonDeletedExcalidrawElement[];
};
export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo(
({ actionManager, items, top, left }: ContextMenuProps) => {
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
if (
item &&
(item === CONTEXT_MENU_SEPARATOR ||
!item.contextItemPredicate ||
item.contextItemPredicate(
elements,
appState,
actionManager.app.props,
actionManager.app,
))
) {
acc.push(item);
}
return acc;
}, []);
return (
<Popover
onCloseRequest={() => setAppState({ contextMenu: null })}
top={top}
left={left}
fitInViewport={true}
offsetLeft={appState.offsetLeft}
offsetTop={appState.offsetTop}
viewportWidth={appState.width}
viewportHeight={appState.height}
const ContextMenu = ({
options,
onCloseRequest,
top,
left,
actionManager,
appState,
elements,
}: ContextMenuProps) => {
return (
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
offsetLeft={appState.offsetLeft}
offsetTop={appState.offsetTop}
viewportWidth={appState.width}
viewportHeight={appState.height}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{filteredItems.map((item, idx) => {
if (item === CONTEXT_MENU_SEPARATOR) {
if (
!filteredItems[idx - 1] ||
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
) {
return null;
}
return <hr key={idx} className="context-menu-item-separator" />;
}
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = item.name;
let label = "";
if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") {
label = t(item.contextItemLabel(elements, appState));
} else {
label = t(item.contextItemLabel);
}
const actionName = option.name;
let label = "";
if (option.contextItemLabel) {
if (typeof option.contextItemLabel === "function") {
label = t(option.contextItemLabel(elements, appState));
} else {
label = t(option.contextItemLabel);
}
return (
<li
key={idx}
data-testid={actionName}
onClick={() => {
// we need update state before executing the action in case
// the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state.
setAppState({ contextMenu: null }, () => {
actionManager.executeAction(item, "contextMenu");
});
}}
}
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() =>
actionManager.executeAction(option, "contextMenu")
}
>
<button
className={clsx("context-menu-item", {
dangerous: actionName === "deleteSelectedElements",
checkmark: item.checked?.(appState),
})}
>
<div className="context-menu-item__label">{label}</div>
<kbd className="context-menu-item__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
};
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
let contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
return contextMenuNode;
}
contextMenuNode = document.createElement("div");
container
.querySelector(".excalidraw-contextMenuContainer")!
.appendChild(contextMenuNode);
contextMenuNodeByContainer.set(container, contextMenuNode);
return contextMenuNode;
};
type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[];
top: ContextMenuProps["top"];
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
elements: readonly NonDeletedExcalidrawElement[];
};
const handleClose = (container: HTMLElement) => {
const contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
};
export default {
push(params: ContextMenuParams) {
const options = Array.of<ContextMenuOption>();
params.options.forEach((option) => {
if (option) {
options.push(option);
}
});
if (options.length) {
render(
<ContextMenu
top={params.top}
left={params.left}
options={options}
onCloseRequest={() => handleClose(params.container)}
actionManager={params.actionManager}
appState={params.appState}
elements={params.elements}
/>,
getContextMenuNode(params.container),
);
}
},
);
};

View File

@@ -2,11 +2,7 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import {
useExcalidrawContainer,
useDevice,
useExcalidrawSetAppState,
} from "../components/App";
import { useExcalidrawContainer, useDevice } from "../components/App";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, CloseIcon } from "./icons";
@@ -14,8 +10,8 @@ import { Island } from "./Island";
import { Modal } from "./Modal";
import { AppState } from "../types";
import { queryFocusableElements } from "../utils";
import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
export interface DialogProps {
children: React.ReactNode;
@@ -71,12 +67,12 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
const onClose = () => {
setAppState({ openMenu: null });
setIsLibraryMenuOpen(false);
setIsMenuOpen(false);
setIsDropdownOpen(false);
(lastActiveElement as HTMLElement).focus();
props.onCloseRequest();
};

View File

@@ -1,8 +1,8 @@
import { shield } from "../../components/icons";
import { Tooltip } from "../../components/Tooltip";
import { t } from "../../i18n";
import { t } from "../i18n";
import { shield } from "./icons";
import { Tooltip } from "./Tooltip";
export const EncryptedIcon = () => (
const EncryptedIcon = () => (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
@@ -15,3 +15,5 @@ export const EncryptedIcon = () => (
</Tooltip>
</a>
);
export default EncryptedIcon;

View File

@@ -1,36 +1,35 @@
import clsx from "clsx";
import { ActionManager } from "../../actions/manager";
import { t } from "../../i18n";
import { AppState, UIChildrenComponents } from "../../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import { AppState, ExcalidrawProps } from "../types";
import {
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "../Actions";
import { useDevice } from "../App";
import { WelcomeScreenHelpArrow } from "../icons";
import { Section } from "../Section";
import Stack from "../Stack";
import WelcomeScreenDecor from "../WelcomeScreenDecor";
} from "./Actions";
import { useDevice } from "./App";
import { WelcomeScreenHelpArrow } from "./icons";
import { Section } from "./Section";
import Stack from "./Stack";
import WelcomeScreenDecor from "./WelcomeScreenDecor";
const Footer = ({
appState,
actionManager,
renderCustomFooter,
showExitZenModeBtn,
renderWelcomeScreen,
footerCenter,
}: {
appState: AppState;
actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean;
footerCenter: UIChildrenComponents["FooterCenter"];
}) => {
const device = useDevice();
const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return (
<footer
role="contentinfo"
@@ -70,7 +69,17 @@ const Footer = ({
</Section>
</Stack.Col>
</div>
{footerCenter}
<div
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
"transition-right disable-pointerEvents": appState.zenModeEnabled,
@@ -98,4 +107,3 @@ const Footer = ({
};
export default Footer;
Footer.displayName = "Footer";

View File

@@ -140,11 +140,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut
label={t("toolBar.line")}
shortcuts={[KEYS.L, KEYS["6"]]}
shortcuts={[KEYS.P, KEYS["6"]]}
/>
<Shortcut
label={t("toolBar.freedraw")}
shortcuts={[KEYS.P, KEYS["7"]]}
shortcuts={["Shift + P", KEYS["7"]]}
/>
<Shortcut
label={t("toolBar.text")}
@@ -157,10 +157,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
getShortcutKey("CtrlOrCmd+Enter"),
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
]}
shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
/>
<Shortcut
label={t("helpDialog.textNewLine")}
@@ -230,14 +227,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.zoomToSelection")}
shortcuts={["Shift+2"]}
/>
<Shortcut
label={t("helpDialog.movePageUpDown")}
shortcuts={["PgUp/PgDn"]}
/>
<Shortcut
label={t("helpDialog.movePageLeftRight")}
shortcuts={["Shift+PgUp/PgDn"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
@@ -300,10 +289,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/>
<Shortcut
label={t("labels.pasteAsPlaintext")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
/>
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
@@ -318,7 +303,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Delete")]}
shortcuts={[getShortcutKey("Del")]}
/>
<Shortcut
label={t("labels.sendToBack")}

View File

@@ -1,7 +1,9 @@
import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
@@ -31,6 +33,19 @@ export const ErrorCanvasPreview = () => {
);
};
const renderPreview = (
content: HTMLCanvasElement | Error,
previewNode: HTMLDivElement,
) => {
unmountComponentAtNode(previewNode);
previewNode.innerHTML = "";
if (content instanceof HTMLCanvasElement) {
previewNode.appendChild(content);
} else {
render(<ErrorCanvasPreview />, previewNode);
}
};
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
@@ -84,7 +99,6 @@ const ImageExportModal = ({
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, true)
@@ -105,16 +119,15 @@ const ImageExportModal = ({
exportPadding,
})
.then((canvas) => {
setRenderError(null);
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas).then(() => {
previewNode.replaceChildren(canvas);
renderPreview(canvas, previewNode);
});
})
.catch((error) => {
console.error(error);
setRenderError(error);
renderPreview(new CanvasError(), previewNode);
});
}, [
appState,
@@ -127,9 +140,7 @@ const ImageExportModal = ({
return (
<div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef}>
{renderError && <ErrorCanvasPreview />}
</div>
<div className="ExportDialog__preview" ref={previewRef} />
{supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>

View File

@@ -1,10 +1,10 @@
import React from "react";
import React, { useState } from "react";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog";
import { exportToFileIcon, LinkIcon } from "./icons";
import { ExportIcon, exportToFileIcon, LinkIcon } from "./icons";
import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
@@ -14,6 +14,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils";
import MenuItem from "./MenuItem";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -93,7 +94,6 @@ export const JSONExportDialog = ({
actionManager,
exportOpts,
canvas,
setAppState,
}: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
@@ -101,15 +101,24 @@ export const JSONExportDialog = ({
actionManager: ActionManager;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => {
setAppState({ openDialog: null });
}, [setAppState]);
setModalIsShown(false);
}, []);
return (
<>
{appState.openDialog === "jsonExport" && (
<MenuItem
icon={ExportIcon}
label={t("buttons.export")}
onClick={() => {
setModalIsShown(true);
}}
dataTestId="json-export-button"
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}

View File

@@ -80,6 +80,12 @@
}
}
.layer-ui__wrapper__footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
}
.layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right,
.disable-zen-mode--visible {

View File

@@ -8,14 +8,8 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { ExportType } from "../scene/types";
import {
AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
UIChildrenComponents,
} from "../types";
import { muteFSAbortError, ReactChildrenToObject } from "../utils";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
@@ -41,17 +35,26 @@ import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDevice } from "../components/App";
import { isMenuOpenAtom, useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer";
import { WelcomeScreenMenuArrow, WelcomeScreenTopToolbarArrow } from "./icons";
import Footer from "./Footer";
import {
ExportImageIcon,
HamburgerMenuIcon,
WelcomeScreenMenuArrow,
WelcomeScreenTopToolbarArrow,
} from "./icons";
import { MenuLinks, Separator } from "./MenuUtils";
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import WelcomeScreen from "./WelcomeScreen";
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
import { useAtom } from "jotai";
import { LanguageList } from "../excalidraw-app/components/LanguageList";
import WelcomeScreenDecor from "./WelcomeScreenDecor";
import MainMenu from "./mainMenu/MainMenu";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import MenuItem from "./MenuItem";
interface LayerUIProps {
actionManager: ActionManager;
@@ -68,6 +71,7 @@ interface LayerUIProps {
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomFooter?: ExcalidrawProps["renderFooter"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -77,9 +81,7 @@ interface LayerUIProps {
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean;
children?: React.ReactNode;
}
const LayerUI = ({
actionManager,
appState,
@@ -94,6 +96,7 @@ const LayerUI = ({
showExitZenModeBtn,
isCollaborating,
renderTopRightUI,
renderCustomFooter,
renderCustomStats,
renderCustomSidebar,
libraryReturnUrl,
@@ -103,13 +106,9 @@ const LayerUI = ({
id,
onImageAction,
renderWelcomeScreen,
children,
}: LayerUIProps) => {
const device = useDevice();
const childrenComponents =
ReactChildrenToObject<UIChildrenComponents>(children);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
@@ -123,7 +122,6 @@ const LayerUI = ({
actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export}
canvas={canvas}
setAppState={setAppState}
/>
);
};
@@ -177,35 +175,9 @@ const LayerUI = ({
);
};
const renderMenu = () => {
return (
childrenComponents.Menu || (
<MainMenu>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.SaveToActiveFile />
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
{onCollabButtonClick && (
<MainMenu.DefaultItems.LiveCollaboration
onSelect={onCollabButtonClick}
isCollaborating={isCollaborating}
/>
)}
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials />
</MainMenu.Group>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
)
);
};
const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom);
const menuRef = useOutsideClickHook(() => setIsMenuOpen(false));
const renderCanvasActions = () => (
<div style={{ position: "relative" }}>
<WelcomeScreenDecor
@@ -216,7 +188,87 @@ const LayerUI = ({
<div>{t("welcomeScreen.menuHints")}</div>
</div>
</WelcomeScreenDecor>
{renderMenu()}
<button
data-prevent-outside-click
className={clsx("menu-button", "zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
onClick={() => setIsMenuOpen(!isMenuOpen)}
type="button"
data-testid="menu-button"
>
{HamburgerMenuIcon}
</button>
{isMenuOpen && (
<div
ref={menuRef}
style={{ position: "absolute", top: "100%", marginTop: ".25rem" }}
>
<Section heading="canvasActions">
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island
className="menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{!appState.viewModeEnabled &&
actionManager.renderAction("loadScene")}
{/* // TODO barnabasmolnar/editor-redesign */}
{/* is this fine here? */}
{appState.fileHandle &&
actionManager.renderAction("saveToActiveFile")}
{renderJSONExportDialog()}
{UIOptions.canvasActions.saveAsImage && (
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
/>
)}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{actionManager.renderAction("toggleShortcuts", undefined, true)}
{!appState.viewModeEnabled &&
actionManager.renderAction("clearCanvas")}
<Separator />
<MenuLinks />
<Separator />
<div
style={{
display: "flex",
flexDirection: "column",
rowGap: ".5rem",
}}
>
<div>{actionManager.renderAction("toggleTheme")}</div>
<div style={{ padding: "0 0.625rem" }}>
<LanguageList style={{ width: "100%" }} />
</div>
{!appState.viewModeEnabled && (
<div>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
)}
</div>
</Island>
</Section>
</div>
)}
</div>
);
@@ -347,7 +399,10 @@ const LayerUI = ({
},
)}
>
<UserList collaborators={appState.collaborators} />
<UserList
collaborators={appState.collaborators}
actionManager={actionManager}
/>
{onCollabButtonClick && (
<CollabButton
isInHamburgerMenu={false}
@@ -400,7 +455,6 @@ const LayerUI = ({
/>
)}
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
<PasteChartDialog
setAppState={setAppState}
@@ -427,12 +481,12 @@ const LayerUI = ({
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars}
device={device}
renderMenu={renderMenu}
/>
)}
@@ -460,10 +514,9 @@ const LayerUI = ({
renderWelcomeScreen={renderWelcomeScreen}
appState={appState}
actionManager={actionManager}
renderCustomFooter={renderCustomFooter}
showExitZenModeBtn={showExitZenModeBtn}
footerCenter={childrenComponents.FooterCenter}
/>
{appState.showStats && (
<Stats
appState={appState}
@@ -510,6 +563,7 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
return (
prev.renderCustomFooter === next.renderCustomFooter &&
prev.renderTopRightUI === next.renderTopRightUI &&
prev.renderCustomStats === next.renderCustomStats &&
prev.renderCustomSidebar === next.renderCustomSidebar &&

View File

@@ -129,27 +129,4 @@
padding-right: 0;
}
}
.layer-ui__sidebar__header .dropdown-menu {
&.dropdown-menu--mobile {
top: 100%;
}
.dropdown-menu-container {
--gap: 0;
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
width: 196px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
}
}
}

View File

@@ -13,15 +13,14 @@ import {
import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils";
import { atom, useAtom } from "jotai";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
export const isLibraryMenuOpenAtom = atom(false);
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import MenuItem from "./MenuItem";
import { isDropdownOpenAtom } from "./App";
const getSelectedItems = (
libraryItems: LibraryItems,
@@ -46,9 +45,7 @@ export const LibraryMenuHeader: React.FC<{
appState,
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom,
);
const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -176,86 +173,85 @@ export const LibraryMenuHeader: React.FC<{
});
};
const renderLibraryMenu = () => {
return (
<DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger
className="Sidebar__dropdown-btn"
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
>
{DotsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsLibraryMenuOpen(false)}
className="library-menu"
>
{!itemsSelected && (
<DropdownMenu.Item
onSelect={onLibraryImport}
icon={LoadIcon}
dataTestId="lib-dropdown--load"
>
{t("buttons.load")}
</DropdownMenu.Item>
)}
{!!items.length && (
<DropdownMenu.Item
onSelect={onLibraryExport}
icon={ExportIcon}
dataTestId="lib-dropdown--export"
>
{t("buttons.export")}
</DropdownMenu.Item>
)}
{!!items.length && (
<DropdownMenu.Item
onSelect={() => setShowRemoveLibAlert(true)}
icon={TrashIcon}
>
{resetLabel}
</DropdownMenu.Item>
)}
{itemsSelected && (
<DropdownMenu.Item
icon={publishIcon}
onSelect={() => setShowPublishLibraryDialog(true)}
dataTestId="lib-dropdown--remove"
>
{t("buttons.publishLibrary")}
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
);
};
const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom);
const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false));
return (
<div style={{ position: "relative" }}>
{renderLibraryMenu()}
<button
type="button"
className="Sidebar__dropdown-btn"
data-prevent-outside-click
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
{DotsIcon}
</button>
{selectedItems.length > 0 && (
<div className="library-actions-counter">{selectedItems.length}</div>
)}
{showRemoveLibAlert && renderRemoveLibAlert()}
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
{isDropdownOpen && (
<div
className="Sidebar__dropdown-content menu-container"
ref={dropdownRef}
>
{!itemsSelected && (
<MenuItem
label={t("buttons.load")}
icon={LoadIcon}
dataTestId="lib-dropdown--load"
onClick={onLibraryImport}
/>
)}
appState={appState}
onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
onSelectItems(selectedItems.filter((_id) => _id !== id))
}
/>
{showRemoveLibAlert && renderRemoveLibAlert()}
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
)}
appState={appState}
onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
onSelectItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
{!!items.length && (
<>
<MenuItem
label={t("buttons.export")}
icon={ExportIcon}
onClick={onLibraryExport}
dataTestId="lib-dropdown--export"
/>
<MenuItem
label={resetLabel}
icon={TrashIcon}
onClick={() => setShowRemoveLibAlert(true)}
dataTestId="lib-dropdown--remove"
/>
</>
)}
{itemsSelected && (
<MenuItem
label={t("buttons.publishLibrary")}
icon={publishIcon}
dataTestId="lib-dropdown--publish"
onClick={() => setShowPublishLibraryDialog(true)}
/>
)}
</div>
)}
{publishLibSuccess && renderPublishSuccess()}
</div>
);
};

View File

@@ -44,7 +44,6 @@ export const LibraryUnit = ({
},
null,
);
svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML;
})();

85
src/components/Menu.scss Normal file
View File

@@ -0,0 +1,85 @@
@import "../css/variables.module";
.excalidraw {
.menu-container {
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
}
.menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.menu-item {
display: flex;
background-color: transparent;
border: 0;
align-items: center;
padding: 0 0.625rem;
height: 2rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
cursor: pointer;
border-radius: var(--border-radius-md);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
}
&:hover {
background-color: var(--button-hover);
text-decoration: none;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
&.active-collab {
background-color: #ecfdf5;
color: #064e3c;
}
}
&.theme--dark {
.menu-item {
color: var(--color-gray-40);
&.active-collab {
background-color: #064e3c;
color: #ecfdf5;
}
}
.menu-container {
background-color: var(--color-gray-90) !important;
}
}
}

View File

@@ -0,0 +1,37 @@
import clsx from "clsx";
import "./Menu.scss";
interface MenuProps {
icon: JSX.Element;
onClick: () => void;
label: string;
dataTestId: string;
shortcut?: string;
isCollaborating?: boolean;
}
const MenuItem = ({
icon,
onClick,
label,
dataTestId,
shortcut,
isCollaborating,
}: MenuProps) => {
return (
<button
className={clsx("menu-item", { "active-collab": isCollaborating })}
aria-label={label}
onClick={onClick}
data-testid={dataTestId}
title={label}
type="button"
>
<div className="menu-item__icon">{icon}</div>
<div className="menu-item__text">{label}</div>
{shortcut && <div className="menu-item__shortcut">{shortcut}</div>}
</button>
);
};
export default MenuItem;

View File

@@ -0,0 +1,53 @@
import { GithubIcon, DiscordIcon, PlusPromoIcon, TwitterIcon } from "./icons";
export const MenuLinks = () => (
<>
<a
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
target="_blank"
rel="noreferrer"
className="menu-item"
style={{ color: "var(--color-promo)" }}
>
<div className="menu-item__icon">{PlusPromoIcon}</div>
<div className="menu-item__text">Excalidraw+</div>
</a>
<a
className="menu-item"
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{GithubIcon}</div>
<div className="menu-item__text">GitHub</div>
</a>
<a
className="menu-item"
target="_blank"
href="https://discord.gg/UexuTaE"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{DiscordIcon}</div>
<div className="menu-item__text">Discord</div>
</a>
<a
className="menu-item"
target="_blank"
href="https://twitter.com/excalidraw"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{TwitterIcon}</div>
<div className="menu-item__text">Twitter</div>
</a>
</>
);
export const Separator = () => (
<div
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
}}
/>
);

View File

@@ -11,13 +11,18 @@ import { HintViewer } from "./HintViewer";
import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton";
import { UserList } from "./UserList";
import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import { MenuLinks, Separator } from "./MenuUtils";
import WelcomeScreen from "./WelcomeScreen";
import MenuItem from "./MenuItem";
import { ExportImageIcon } from "./icons";
type MobileMenuProps = {
appState: AppState;
@@ -31,7 +36,10 @@ type MobileMenuProps = {
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
isMobile: boolean,
@@ -41,25 +49,27 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null;
device: Device;
renderWelcomeScreen?: boolean;
renderMenu: () => React.ReactNode;
};
export const MobileMenu = ({
appState,
elements,
actionManager,
renderJSONExportDialog,
renderImageExportDialog,
setAppState,
onCollabButtonClick,
onLockToggle,
onPenModeToggle,
canvas,
isCollaborating,
renderCustomFooter,
onImageAction,
renderTopRightUI,
renderCustomStats,
renderSidebars,
device,
renderWelcomeScreen,
renderMenu,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
@@ -141,12 +151,16 @@ export const MobileMenu = ({
const renderAppToolbar = () => {
if (appState.viewModeEnabled) {
return <div className="App-toolbar-content">{renderMenu()}</div>;
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
</div>
);
}
return (
<div className="App-toolbar-content">
{renderMenu()}
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
@@ -158,6 +172,58 @@ export const MobileMenu = ({
);
};
const renderCanvasActions = () => {
if (appState.viewModeEnabled) {
return (
<>
{renderJSONExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{renderImageExportDialog()}
</>
);
}
return (
<>
{!appState.viewModeEnabled && actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{actionManager.renderAction("toggleShortcuts", undefined, true)}
{!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")}
<Separator />
<MenuLinks />
<Separator />
{!appState.viewModeEnabled && (
<div style={{ marginBottom: ".5rem" }}>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
)}
{actionManager.renderAction("toggleTheme")}
</>
);
};
return (
<>
{renderSidebars()}
@@ -182,9 +248,28 @@ export const MobileMenu = ({
}}
>
<Island padding={0}>
{appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={2}>
{renderCanvasActions()}
{renderCustomFooter?.(true, appState)}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList
mobile
collaborators={appState.collaborators}
actionManager={actionManager}
/>
</fieldset>
)}
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}

View File

@@ -3,7 +3,7 @@
.excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: 9999;
z-index: 10;
}
.Modal {

View File

@@ -46,7 +46,6 @@ const ChartPreviewBtn = (props: {
},
null, // files
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);

View File

@@ -3,6 +3,24 @@
.excalidraw {
.Sidebar {
&__dropdown-content {
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
margin-top: 0.25rem;
width: 180px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
}
&__close-btn,
&__pin-btn,
&__dropdown-btn {

View File

@@ -7,7 +7,6 @@
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.625rem;
&:empty {

View File

@@ -4,16 +4,16 @@ import React from "react";
import clsx from "clsx";
import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip";
import { useExcalidrawActionManager } from "./App";
import { ActionManager } from "../actions/manager";
export const UserList: React.FC<{
className?: string;
mobile?: boolean;
collaborators: AppState["collaborators"];
}> = ({ className, mobile, collaborators }) => {
const actionManager = useExcalidrawActionManager();
actionManager: ActionManager;
}> = ({ className, mobile, collaborators, actionManager }) => {
const uniqueCollaborators = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set(
// filter on user id, else fall back on unique socketId
@@ -44,6 +44,26 @@ export const UserList: React.FC<{
);
});
// TODO barnabasmolnar/editor-redesign
// probably remove before shipping :)
// 20 fake collaborators; for easy, convenient debug purposes ˇˇ
// const avatars = Array.from({ length: 20 }).map((_, index) => {
// const avatarJSX = actionManager.renderAction("goToCollaborator", [
// index.toString(),
// {
// username: `User ${index}`,
// },
// ]);
// return mobile ? (
// <Tooltip label={`User ${index}`} key={index}>
// {avatarJSX}
// </Tooltip>
// ) : (
// <React.Fragment key={index}>{avatarJSX}</React.Fragment>
// );
// });
return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars}

View File

@@ -1,12 +1,24 @@
import { useAtom } from "jotai";
import { actionLoadScene, actionShortcuts } from "../actions";
import { ActionManager } from "../actions/manager";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { isExcalidrawPlusSignedUser } from "../constants";
import { COOKIES } from "../constants";
import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab";
import { t } from "../i18n";
import { AppState } from "../types";
import { ExcalLogo, HelpIcon, LoadIcon, PlusPromoIcon } from "./icons";
import {
ExcalLogo,
HelpIcon,
LoadIcon,
PlusPromoIcon,
UsersIcon,
} from "./icons";
import "./WelcomeScreen.scss";
const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
const WelcomeScreenItem = ({
label,
shortcut,
@@ -56,6 +68,8 @@ const WelcomeScreen = ({
appState: AppState;
actionManager: ActionManager;
}) => {
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
let subheadingJSX;
if (isExcalidrawPlusSignedUser) {
@@ -99,6 +113,12 @@ const WelcomeScreen = ({
icon={LoadIcon}
/>
)}
<WelcomeScreenItem
label={t("labels.liveCollaboration")}
shortcut={null}
onClick={() => setCollabDialogShown(true)}
icon={UsersIcon}
/>
<WelcomeScreenItem
onClick={() => actionManager.executeAction(actionShortcuts)}
label={t("helpDialog.title")}

View File

@@ -1,127 +0,0 @@
@import "../../css/variables.module";
.excalidraw {
.dropdown-menu {
position: absolute;
top: 100%;
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
row-gap: 0.75rem;
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
transition: box-shadow 0.5s ease-in-out;
&.zen-mode {
box-shadow: none;
}
}
}
.dropdown-menu-container {
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
}
.dropdown-menu-item-base {
display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
}
.dropdown-menu-item {
background-color: transparent;
border: 0;
align-items: center;
height: 2rem;
cursor: pointer;
border-radius: var(--border-radius-md);
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
}
&:hover {
background-color: var(--button-hover);
text-decoration: none;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
}
.dropdown-menu-item-custom {
margin-top: 0.5rem;
}
.dropdown-menu-group-title {
font-size: 14px;
text-align: left;
margin: 10px 0;
font-weight: 500;
}
}
&.theme--dark {
.dropdown-menu-item {
color: var(--color-gray-40);
}
.dropdown-menu-container {
background-color: var(--color-gray-90) !important;
}
}
.dropdown-menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&--mobile {
border: none;
margin: 0;
padding: 0;
width: var(--default-button-size);
height: var(--default-button-size);
}
}
}

View File

@@ -1,43 +0,0 @@
import React from "react";
import DropdownMenuTrigger from "./DropdownMenuTrigger";
import DropdownMenuItem from "./DropdownMenuItem";
import MenuSeparator from "./DropdownMenuSeparator";
import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuItemLink from "./DropdownMenuItemLink";
import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
import {
getMenuContentComponent,
getMenuTriggerComponent,
} from "./dropdownMenuUtils";
import "./DropdownMenu.scss";
const DropdownMenu = ({
children,
open,
}: {
children?: React.ReactNode;
open: boolean;
}) => {
const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children);
return (
<>
{MenuTriggerComp}
{open && MenuContentComp}
</>
);
};
DropdownMenu.Trigger = DropdownMenuTrigger;
DropdownMenu.Content = DropdownMenuContent;
DropdownMenu.Item = DropdownMenuItem;
DropdownMenu.ItemLink = DropdownMenuItemLink;
DropdownMenu.ItemCustom = DropdownMenuItemCustom;
DropdownMenu.Group = DropdownMenuGroup;
DropdownMenu.Separator = MenuSeparator;
export default DropdownMenu;
DropdownMenu.displayName = "DropdownMenu";

View File

@@ -1,51 +0,0 @@
import { useOutsideClickHook } from "../../hooks/useOutsideClick";
import { Island } from "../Island";
import { useDevice } from "../App";
import clsx from "clsx";
import Stack from "../Stack";
const MenuContent = ({
children,
onClickOutside,
className = "",
style,
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
className?: string;
style?: React.CSSProperties;
}) => {
const device = useDevice();
const menuRef = useOutsideClickHook(() => {
onClickOutside?.();
});
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.isMobile,
}).trim();
return (
<div
ref={menuRef}
className={classNames}
style={style}
data-testid="dropdown-menu"
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
{device.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</div>
);
};
export default MenuContent;
MenuContent.displayName = "DropdownMenuContent";

View File

@@ -1,23 +0,0 @@
import React from "react";
const MenuGroup = ({
children,
className = "",
style,
title,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
title?: string;
}) => {
return (
<div className={`dropdown-menu-group ${className}`} style={style}>
{title && <p className="dropdown-menu-group-title">{title}</p>}
{children}
</div>
);
};
export default MenuGroup;
MenuGroup.displayName = "DropdownMenuGroup";

View File

@@ -1,45 +0,0 @@
import React from "react";
import MenuItemContent from "./DropdownMenuItemContent";
export const getDrodownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
};
const DropdownMenuItem = ({
icon,
onSelect,
children,
dataTestId,
shortcut,
className,
style,
ariaLabel,
}: {
icon?: JSX.Element;
onSelect: () => void;
children: React.ReactNode;
dataTestId?: string;
shortcut?: string;
className?: string;
style?: React.CSSProperties;
ariaLabel?: string;
}) => {
return (
<button
aria-label={ariaLabel}
onClick={onSelect}
data-testid={dataTestId}
title={ariaLabel}
type="button"
className={getDrodownMenuItemClassName(className)}
style={style}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</button>
);
};
export default DropdownMenuItem;
DropdownMenuItem.displayName = "DropdownMenuItem";

View File

@@ -1,23 +0,0 @@
import { useDevice } from "../App";
const MenuItemContent = ({
icon,
shortcut,
children,
}: {
icon?: JSX.Element;
shortcut?: string;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
<div className="dropdown-menu-item__icon">{icon}</div>
<div className="dropdown-menu-item__text">{children}</div>
{shortcut && !device.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export default MenuItemContent;

View File

@@ -1,23 +0,0 @@
const DropdownMenuItemCustom = ({
children,
className = "",
style,
dataTestId,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
dataTestId?: string;
}) => {
return (
<div
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
style={style}
data-testid={dataTestId}
>
{children}
</div>
);
};
export default DropdownMenuItemCustom;

View File

@@ -1,42 +0,0 @@
import MenuItemContent from "./DropdownMenuItemContent";
import React from "react";
import { getDrodownMenuItemClassName } from "./DropdownMenuItem";
const DropdownMenuItemLink = ({
icon,
dataTestId,
shortcut,
href,
children,
className = "",
style,
ariaLabel,
}: {
icon?: JSX.Element;
children: React.ReactNode;
dataTestId?: string;
shortcut?: string;
className?: string;
href: string;
style?: React.CSSProperties;
ariaLabel?: string;
}) => {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={getDrodownMenuItemClassName(className)}
style={style}
data-testid={dataTestId}
title={ariaLabel}
aria-label={ariaLabel}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</a>
);
};
export default DropdownMenuItemLink;
DropdownMenuItemLink.displayName = "DropdownMenuItemLink";

View File

@@ -1,14 +0,0 @@
import React from "react";
const MenuSeparator = () => (
<div
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
}}
/>
);
export default MenuSeparator;
MenuSeparator.displayName = "DropdownMenuSeparator";

View File

@@ -1,37 +0,0 @@
import clsx from "clsx";
import { useDevice, useExcalidrawAppState } from "../App";
const MenuTrigger = ({
className = "",
children,
onToggle,
}: {
className?: string;
children: React.ReactNode;
onToggle: () => void;
}) => {
const appState = useExcalidrawAppState();
const device = useDevice();
const classNames = clsx(
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile,
},
).trim();
return (
<button
data-prevent-outside-click
className={classNames}
onClick={onToggle}
type="button"
data-testid="dropdown-menu-button"
>
{children}
</button>
);
};
export default MenuTrigger;
MenuTrigger.displayName = "DropdownMenuTrigger";

View File

@@ -1,35 +0,0 @@
import React from "react";
export const getMenuTriggerComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuTrigger",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
export const getMenuContentComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};

View File

@@ -1,10 +0,0 @@
.footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
display: flex;
width: 100%;
justify-content: flex-start;
}

View File

@@ -1,20 +0,0 @@
import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import "./FooterCenter.scss";
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
const appState = useExcalidrawAppState();
return (
<div
className={clsx("footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
>
{children}
</div>
);
};
export default FooterCenter;
FooterCenter.displayName = "FooterCenter";

View File

@@ -1470,11 +1470,11 @@ export const TextAlignRightIcon = createIcon(
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<g
strokeWidth="1.5"
stroke-width="1.5"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="4" x2="20" y2="4" />
@@ -1488,11 +1488,11 @@ export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<g
strokeWidth="2"
stroke-width="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="20" x2="20" y2="20" />
@@ -1506,11 +1506,11 @@ export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<g
strokeWidth="1.5"
stroke-width="1.5"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="12" x2="9" y2="12" />

View File

@@ -1,174 +0,0 @@
import clsx from "clsx";
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n";
import {
useExcalidrawAppState,
useExcalidrawSetAppState,
useExcalidrawActionManager,
} from "../App";
import { ExportIcon, ExportImageIcon, UsersIcon } from "../icons";
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
export const LoadScene = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return actionManager.renderAction("loadScene");
};
LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!appState.fileHandle) {
return null;
}
return actionManager.renderAction("saveToActiveFile");
};
SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState();
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
icon={ExportImageIcon}
dataTestId="image-export-button"
onSelect={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
ariaLabel={t("buttons.exportImage")}
>
{t("buttons.exportImage")}
</DropdownMenuItem>
);
};
SaveAsImage.displayName = "SaveAsImage";
export const Help = () => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
return actionManager.renderAction("toggleShortcuts", undefined, true);
};
Help.displayName = "Help";
export const ClearCanvas = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return actionManager.renderAction("clearCanvas");
};
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
return actionManager.renderAction("toggleTheme");
};
ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
);
};
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItem
icon={ExportIcon}
onSelect={() => {
setAppState({ openDialog: "jsonExport" });
}}
dataTestId="json-export-button"
ariaLabel={t("buttons.export")}
>
{t("buttons.export")}
</DropdownMenuItem>
);
};
Export.displayName = "Export";
export const Socials = () => (
<>
<DropdownMenuItemLink
icon={GithubIcon}
href="https://github.com/excalidraw/excalidraw"
ariaLabel="GitHub"
>
GitHub
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={DiscordIcon}
href="https://discord.gg/UexuTaE"
ariaLabel="Discord"
>
Discord
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={TwitterIcon}
href="https://twitter.com/excalidraw"
ariaLabel="Twitter"
>
Twitter
</DropdownMenuItemLink>
</>
);
Socials.displayName = "Socials";
export const LiveCollaboration = ({
onSelect,
isCollaborating,
}: {
onSelect: () => void;
isCollaborating: boolean;
}) => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
dataTestId="collab-button"
icon={UsersIcon}
className={clsx({
"active-collab": isCollaborating,
})}
onSelect={onSelect}
>
{t("labels.liveCollaboration")}
</DropdownMenuItem>
);
};
LiveCollaboration.displayName = "LiveCollaboration";

View File

@@ -1,56 +0,0 @@
import React from "react";
import {
useDevice,
useExcalidrawAppState,
useExcalidrawSetAppState,
} from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import * as DefaultItems from "./DefaultItems";
import { UserList } from "../UserList";
import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons";
const MainMenu = ({ children }: { children?: React.ReactNode }) => {
const device = useDevice();
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile
? undefined
: () => setAppState({ openMenu: null });
return (
<DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger
onToggle={() => {
setAppState({
openMenu: appState.openMenu === "canvas" ? null : "canvas",
});
}}
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content onClickOutside={onClickOutside}>
{children}
{device.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper">
<legend>{t("labels.collaborators")}</legend>
<UserList mobile={true} collaborators={appState.collaborators} />
</fieldset>
)}
</DropdownMenu.Content>
</DropdownMenu>
);
};
MainMenu.Trigger = DropdownMenu.Trigger;
MainMenu.Item = DropdownMenu.Item;
MainMenu.ItemLink = DropdownMenu.ItemLink;
MainMenu.ItemCustom = DropdownMenu.ItemCustom;
MainMenu.Group = DropdownMenu.Group;
MainMenu.Separator = DropdownMenu.Separator;
MainMenu.DefaultItems = DefaultItems;
export default MainMenu;
MainMenu.displayName = "Menu";

View File

@@ -130,6 +130,12 @@ export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
export const MODES = {
VIEW: "viewMode",
ZEN: "zenMode",
GRID: "gridMode",
};
export const THEME_FILTER = cssVariables.themeFilter;
export const URL_QUERY_KEYS = {
@@ -210,32 +216,6 @@ export const TEXT_ALIGN = {
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
// Radius represented as 25% of element's largest side (width/height).
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
// below the cutoff size.
export const DEFAULT_PROPORTIONAL_RADIUS = 0.25;
// Fixed radius for the ADAPTIVE_RADIUS algorithm. In pixels.
export const DEFAULT_ADAPTIVE_RADIUS = 32;
// roundness type (algorithm)
export const ROUNDNESS = {
// Used for legacy rounding (rectangles), which currently works the same
// as PROPORTIONAL_RADIUS, but we need to differentiate for UI purposes and
// forwards-compat.
LEGACY: 1,
// Used for linear elements & diamonds
PROPORTIONAL_RADIUS: 2,
// Current default algorithm for rectangles, using fixed pixel radius.
// It's working similarly to a regular border-radius, but attemps to make
// radius visually similar across differnt element sizes, especially
// very large and very small elements.
//
// NOTE right now we don't allow configuration and use a constant radius
// (see DEFAULT_ADAPTIVE_RADIUS constant)
ADAPTIVE_RADIUS: 3,
} as const;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;
@@ -243,7 +223,3 @@ export const COOKIES = {
/** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);

View File

@@ -569,20 +569,6 @@
display: none;
}
}
.UserList-Wrapper {
margin: 0;
padding: 0;
border: none;
text-align: left;
legend {
display: block;
font-size: 0.75rem;
font-weight: 400;
margin: 0 0 0.25rem;
padding: 0;
}
}
}
.ErrorSplash.excalidraw {

View File

@@ -154,8 +154,7 @@ class Library {
return this.setLibrary(() => {
return new Promise<LibraryItems>(async (resolve, reject) => {
try {
const source = await (typeof libraryItems === "function" &&
!(libraryItems instanceof Blob)
const source = await (typeof libraryItems === "function"
? libraryItems(this.lastLibraryItems)
: libraryItems);

View File

@@ -1,9 +1,7 @@
import {
ExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
StrokeRoundness,
} from "../element/types";
import {
AppState,
@@ -18,7 +16,7 @@ import {
isInvisiblySmallElement,
refreshTextDimensions,
} from "../element";
import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random";
import {
DEFAULT_FONT_FAMILY,
@@ -26,14 +24,12 @@ import {
DEFAULT_VERTICAL_ALIGN,
PRECEDING_ELEMENT_KEY,
FONT_FAMILY,
ROUNDNESS,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import oc from "open-color";
type RestoredAppState = Omit<
AppState,
@@ -77,8 +73,6 @@ const restoreElementWithProperties = <
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */
strokeSharpness?: StrokeRoundness;
/** metadata that may be present in elements during collaboration */
[PRECEDING_ELEMENT_KEY]?: string;
},
@@ -111,23 +105,15 @@ const restoreElementWithProperties = <
angle: element.angle || 0,
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || oc.black,
backgroundColor: element.backgroundColor || "transparent",
strokeColor: element.strokeColor,
backgroundColor: element.backgroundColor,
width: element.width || 0,
height: element.height || 0,
seed: element.seed ?? 1,
groupIds: element.groupIds ?? [],
roundness: element.roundness
? element.roundness
: element.strokeSharpness === "round"
? {
// for old elements that would now use adaptive radius algo,
// use legacy algo instead
type: isUsingAdaptiveRadius(element.type)
? ROUNDNESS.LEGACY
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
strokeSharpness:
element.strokeSharpness ??
(isLinearElementType(element.type) ? "round" : "sharp"),
boundElements: element.boundElementIds
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
@@ -153,7 +139,7 @@ const restoreElementWithProperties = <
const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
refreshDimensions = false,
refreshDimensions = true,
): typeof element | null => {
switch (element.type) {
case "text":
@@ -249,99 +235,14 @@ const restoreElement = (
}
};
/**
* Repairs contaienr element's boundElements array by removing duplicates and
* fixing containerId of bound elements if not present. Also removes any
* bound elements that do not exist in the elements array.
*
* NOTE mutates elements.
*/
const repairContainerElement = (
container: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (container.boundElements) {
// copy because we're not cloning on restore, and we don't want to mutate upstream
const boundElements = container.boundElements.slice();
// dedupe bindings & fix boundElement.containerId if not set already
const boundIds = new Set<ExcalidrawElement["id"]>();
container.boundElements = boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const boundElement = elementsMap.get(binding.id);
if (boundElement && !boundIds.has(binding.id)) {
boundIds.add(binding.id);
if (boundElement.isDeleted) {
return acc;
}
acc.push(binding);
if (
isTextElement(boundElement) &&
// being slightly conservative here, preserving existing containerId
// if defined, lest boundElements is stale
!boundElement.containerId
) {
(boundElement as Mutable<ExcalidrawTextElement>).containerId =
container.id;
}
}
return acc;
},
[],
);
}
};
/**
* Repairs target bound element's container's boundElements array,
* or removes contaienrId if container does not exist.
*
* NOTE mutates elements.
*/
const repairBoundElement = (
boundElement: Mutable<ExcalidrawTextElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
const container = boundElement.containerId
? elementsMap.get(boundElement.containerId)
: null;
if (!container) {
boundElement.containerId = null;
return;
}
if (boundElement.isDeleted) {
return;
}
if (
container.boundElements &&
!container.boundElements.find((binding) => binding.id === boundElement.id)
) {
// copy because we're not cloning on restore, and we don't want to mutate upstream
const boundElements = (
container.boundElements || (container.boundElements = [])
).slice();
boundElements.push({ type: "text", id: boundElement.id });
container.boundElements = boundElements;
}
};
export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
refreshDimensions = false,
refreshDimensions = true,
): ExcalidrawElement[] => {
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (elements || []).reduce((elements, element) => {
return (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
@@ -359,18 +260,6 @@ export const restoreElements = (
}
return elements;
}, [] as ExcalidrawElement[]);
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) {
repairContainerElement(element, restoredElementsMap);
}
}
return restoredElements;
};
const coalesceAppStateValue = <
@@ -498,7 +387,7 @@ export const restore = (
localElements: readonly ExcalidrawElement[] | null | undefined,
): RestoredDataState => {
return {
elements: restoreElements(data?.elements, localElements),
elements: restoreElements(data?.elements, localElements, true),
appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {},
};

View File

@@ -26,7 +26,6 @@ import Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -362,10 +361,6 @@ export const updateBoundElements = (
endBinding,
changedElement as ExcalidrawBindableElement,
);
const boundText = getBoundTextElement(element);
if (boundText) {
handleBindTextResize(element, false);
}
});
};

View File

@@ -1,4 +1,3 @@
import { ROUNDNESS } from "../constants";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
@@ -23,7 +22,6 @@ const _ce = ({
backgroundColor: "#000",
fillStyle: "solid",
strokeWidth: 1,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 0,
opacity: 1,
x,

View File

@@ -4,7 +4,6 @@ import {
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
} from "./types";
import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough";
@@ -14,15 +13,8 @@ import {
getShapeForElement,
generateRoughOptions,
} from "../renderer/renderElement";
import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number];
@@ -32,39 +24,17 @@ type MaybeQuadraticSolution = [number | null, number | null] | false;
// This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = (
element: ExcalidrawElement,
includeBoundText: boolean = false,
): [number, number, number, number, number, number] => {
): Bounds => {
if (isFreeDrawElement(element)) {
return getFreeDrawElementAbsoluteCoords(element);
} else if (isLinearElement(element)) {
return LinearElementEditor.getElementAbsoluteCoords(
element,
includeBoundText,
);
} else if (isTextElement(element)) {
const container = getContainerElement(element);
if (isArrowElement(container)) {
const coords = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
);
return [
coords.x,
coords.y,
coords.x + element.width,
coords.y + element.height,
coords.x + element.width / 2,
coords.y + element.height / 2,
];
}
return getLinearElementAbsoluteCoords(element);
}
return [
element.x,
element.y,
element.x + element.width,
element.y + element.height,
element.x + element.width / 2,
element.y + element.height / 2,
];
};
@@ -189,7 +159,7 @@ const getCubicBezierCurveBound = (
return [minX, minY, maxX, maxY];
};
export const getMinMaxXYFromCurvePathOps = (
const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => {
@@ -260,13 +230,59 @@ const getBoundsFromPoints = (
const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement,
): [number, number, number, number, number, number] => {
): [number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
const x1 = minX + element.x;
const y1 = minY + element.y;
const x2 = maxX + element.x;
const y2 = maxY + element.y;
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
};
const getLinearElementAbsoluteCoords = (
element: ExcalidrawLinearElement,
): [number, number, number, number] => {
let coords: [number, number, number, number];
if (element.points.length < 2 || !getShapeForElement(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, y);
return limits;
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else {
const shape = getShapeForElement(element)!;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
}
return coords;
};
export const getArrowheadPoints = (
@@ -378,7 +394,7 @@ const generateLinearElementShape = (
const options = generateRoughOptions(element);
const method = (() => {
if (element.roundness) {
if (element.strokeSharpness !== "sharp") {
return "curve";
}
if (options.fill) {
@@ -404,23 +420,7 @@ const getLinearElementRotatedBounds = (
cy,
element.angle,
);
let coords: [number, number, number, number] = [x, y, x, y];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
[x, y, x, y],
boundTextElement,
);
coords = [
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
}
return coords;
return [x, y, x, y];
}
// first element is always the curve
@@ -429,28 +429,8 @@ const getLinearElementRotatedBounds = (
const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: [number, number, number, number] = [
res[0],
res[1],
res[2],
res[3],
];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
coords,
boundTextElement,
);
coords = [
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
}
return coords;
return getMinMaxXYFromCurvePathOps(ops, transformXY);
};
// We could cache this stuff
@@ -459,7 +439,9 @@ export const getElementBounds = (
): [number, number, number, number] => {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
@@ -561,12 +543,16 @@ export const getResizedElementAbsoluteCoords = (
} else {
// Line
const gen = rough.generator();
const curve = !element.roundness
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(points as [number, number][], generateRoughOptions(element));
const curve =
element.strokeSharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(
points as [number, number][],
generateRoughOptions(element),
);
const ops = getCurvePathOps(curve);
bounds = getMinMaxXYFromCurvePathOps(ops);
@@ -584,11 +570,12 @@ export const getResizedElementAbsoluteCoords = (
export const getElementPointsCoords = (
element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[],
sharpness: ExcalidrawElement["strokeSharpness"],
): [number, number, number, number] => {
// This might be computationally heavey
const gen = rough.generator();
const curve =
element.roundness == null
sharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),

View File

@@ -25,7 +25,6 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
StrokeRoundness,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -37,7 +36,6 @@ import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
import { getBoundTextElement } from "./textElement";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@@ -74,13 +72,6 @@ export const hitTest = (
return isPointHittingElementBoundingBox(element, point, threshold);
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
if (isHittingBoundTextElement) {
return true;
}
}
return isHittingElementNotConsideringBoundingBox(element, appState, point);
};
@@ -92,13 +83,6 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
): boolean => {
const threshold = 10 / appState.zoom.value;
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
// eg for linear elements text can be outside the element bounding box
const boundTextElement = getBoundTextElement(element);
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
return false;
}
return (
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
isPointHittingElementBoundingBox(element, [x, y], threshold)
@@ -111,6 +95,7 @@ export const isHittingElementNotConsideringBoundingBox = (
point: Point,
): boolean => {
const threshold = 10 / appState.zoom.value;
const check = isTextElement(element)
? isStrictlyInside
: isElementDraggableFromInside(element)
@@ -397,7 +382,6 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
if (!getShapeForElement(element)) {
return false;
}
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
args.element,
args.point,
@@ -420,12 +404,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
if (args.check === isInsideCheck) {
const hit = shape.some((subshape) =>
hitTestCurveInside(
subshape,
relX,
relY,
element.roundness ? "round" : "sharp",
),
hitTestCurveInside(subshape, relX, relY, element.strokeSharpness),
);
if (hit) {
return true;
@@ -455,9 +434,8 @@ const pointRelativeToElement = (
pointTuple: Point,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const center = coordsCenter(elementCoords);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point);
@@ -488,8 +466,8 @@ export const pointInAbsoluteCoords = (
const relativizationToElementCenter = (
element: ExcalidrawElement,
): GA.Transform => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse(
@@ -546,8 +524,8 @@ export const determineFocusPoint = (
adjecentPoint: Point,
): Point => {
if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords);
return GAPoint.toTuple(center);
}
const relateToCenter = relativizationToElementCenter(element);
@@ -857,7 +835,7 @@ const hitTestCurveInside = (
drawable: Drawable,
x: number,
y: number,
roundness: StrokeRoundness,
sharpness: ExcalidrawElement["strokeSharpness"],
) => {
const ops = getCurvePathOps(drawable);
const points: Mutable<Point>[] = [];
@@ -881,7 +859,7 @@ const hitTestCurveInside = (
}
}
if (points.length >= 4) {
if (roundness === "sharp") {
if (sharpness === "sharp") {
return isPointInPolygon(points, x, y);
}
const polygonPoints = pointsOnBezierCurves(points, 10, 5);

View File

@@ -4,7 +4,6 @@ import {
ExcalidrawElement,
PointBinding,
ExcalidrawBindableElement,
ExcalidrawTextElementWithContainer,
} from "./types";
import {
distance2d,
@@ -20,12 +19,8 @@ import {
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import {
getCurvePathOps,
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { Point, AppState, PointerCoords } from "../types";
import { getElementPointsCoords } from "./bounds";
import { Point, AppState } from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
@@ -38,15 +33,13 @@ import {
import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks";
import { shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getShapeForElement } from "../renderer/renderElement";
import { DRAGGING_THRESHOLD } from "../constants";
const editorMidPointsCache: {
version: number | null;
points: (Point | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId";
@@ -58,12 +51,6 @@ export class LinearElementEditor {
prevSelectedPointsIndices: readonly number[] | null;
/** index */
lastClickedPoint: number;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
value: Point | null;
index: number | null;
added: boolean;
};
}>;
/** whether you're dragging a point */
@@ -94,13 +81,6 @@ export class LinearElementEditor {
this.pointerDownState = {
prevSelectedPointsIndices: null,
lastClickedPoint: -1,
origin: null,
segmentMidpoint: {
value: null,
index: null,
added: false,
},
};
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
@@ -200,7 +180,6 @@ export class LinearElementEditor {
const draggingPoint = element.points[
linearElementEditor.pointerDownState.lastClickedPoint
] as [number, number] | undefined;
if (selectedPointsIndices && draggingPoint) {
if (
shouldRotateWithDiscreteAngle(event) &&
@@ -263,11 +242,6 @@ export class LinearElementEditor {
};
}),
);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
handleBindTextResize(element, false);
}
}
// suggest bindings for first and last point if selected
@@ -399,14 +373,8 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
): typeof editorMidPointsCache["points"] => {
const boundText = getBoundTextElement(element);
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
!appState.editingLinearElement &&
element.points.length > 2 &&
!boundText
) {
// Since its not needed outside editor unless 2 pointer lines
if (!appState.editingLinearElement && element.points.length > 2) {
return [];
}
if (
@@ -527,7 +495,7 @@ export class LinearElementEditor {
endPoint[0],
endPoint[1],
);
if (element.points.length > 2 && element.roundness) {
if (element.points.length > 2 && element.strokeSharpness === "round") {
distance = getBezierCurveLength(element, endPoint);
}
@@ -541,7 +509,7 @@ export class LinearElementEditor {
endPointIndex: number,
) {
let segmentMidPoint = centerPoint(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
if (element.points.length > 2 && element.strokeSharpness === "round") {
const controlPoints = getControlPointsForBezierCurve(
element,
element.points[endPointIndex],
@@ -583,7 +551,7 @@ export class LinearElementEditor {
}
const midPoints = LinearElementEditor.getEditorMidPoints(element, appState);
let index = 0;
while (index < midPoints.length) {
while (index < midPoints.length - 1) {
if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) {
return index + 1;
}
@@ -602,11 +570,13 @@ export class LinearElementEditor {
didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null;
isMidPoint: boolean;
} {
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false,
hitElement: null,
linearElementEditor: null,
isMidPoint: false,
};
if (!linearElementEditor) {
@@ -619,18 +589,43 @@ export class LinearElementEditor {
if (!element) {
return ret;
}
const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords(
const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords(
linearElementEditor,
scenePointer,
appState,
);
let segmentMidpointIndex = null;
if (segmentMidpoint) {
segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex(
if (segmentMidPoint) {
const index = LinearElementEditor.getSegmentMidPointIndex(
linearElementEditor,
appState,
segmentMidpoint,
segmentMidPoint,
);
const newMidPoint = LinearElementEditor.createPointAt(
element,
segmentMidPoint[0],
segmentMidPoint[1],
appState.gridSize,
);
const points = [
...element.points.slice(0, index),
newMidPoint,
...element.points.slice(index),
];
mutateElement(element, {
points,
});
ret.didAddPoint = true;
ret.isMidPoint = true;
ret.linearElementEditor = {
...linearElementEditor,
selectedPointsIndices: element.points[1],
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1,
},
lastUncommittedPoint: null,
};
}
if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
@@ -653,12 +648,6 @@ export class LinearElementEditor {
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
index: segmentMidpointIndex,
added: false,
},
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
@@ -678,9 +667,10 @@ export class LinearElementEditor {
scenePointer.x,
scenePointer.y,
);
// if we clicked on a point, set the element as hitElement otherwise
// it would get deselected if the point is outside the hitbox area
if (clickedPointIndex >= 0 || segmentMidpoint) {
if (clickedPointIndex >= 0 || segmentMidPoint) {
ret.hitElement = element;
} else {
// You might be wandering why we are storing the binding elements on
@@ -726,12 +716,6 @@ export class LinearElementEditor {
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: clickedPointIndex,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
index: segmentMidpointIndex,
added: false,
},
},
selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint
@@ -1071,6 +1055,7 @@ export class LinearElementEditor {
const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
@@ -1126,94 +1111,6 @@ export class LinearElementEditor {
);
}
static shouldAddMidpoint(
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
appState: AppState,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return false;
}
const { segmentMidpoint } = linearElementEditor.pointerDownState;
if (
segmentMidpoint.added ||
segmentMidpoint.value === null ||
segmentMidpoint.index === null ||
linearElementEditor.pointerDownState.origin === null
) {
return false;
}
const origin = linearElementEditor.pointerDownState.origin!;
const dist = distance2d(
origin.x,
origin.y,
pointerCoords.x,
pointerCoords.y,
);
if (
!appState.editingLinearElement &&
dist < DRAGGING_THRESHOLD / appState.zoom.value
) {
return false;
}
return true;
}
static addMidpoint(
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
appState: AppState,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return;
}
const { segmentMidpoint } = linearElementEditor.pointerDownState;
const ret: {
pointerDownState: LinearElementEditor["pointerDownState"];
selectedPointsIndices: LinearElementEditor["selectedPointsIndices"];
} = {
pointerDownState: linearElementEditor.pointerDownState,
selectedPointsIndices: linearElementEditor.selectedPointsIndices,
};
const midpoint = LinearElementEditor.createPointAt(
element,
pointerCoords.x,
pointerCoords.y,
appState.gridSize,
);
const points = [
...element.points.slice(0, segmentMidpoint.index!),
midpoint,
...element.points.slice(segmentMidpoint.index!),
];
mutateElement(element, {
points,
});
ret.pointerDownState = {
...linearElementEditor.pointerDownState,
segmentMidpoint: {
...linearElementEditor.pointerDownState.segmentMidpoint,
added: true,
},
lastClickedPoint: segmentMidpoint.index!,
};
ret.selectedPointsIndices = [segmentMidpoint.index!];
return ret;
}
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
nextPoints: readonly Point[],
@@ -1221,8 +1118,16 @@ export class LinearElementEditor {
offsetY: number,
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) {
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points);
const nextCoords = getElementPointsCoords(
element,
nextPoints,
element.strokeSharpness || "round",
);
const prevCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness || "round",
);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
@@ -1230,6 +1135,7 @@ export class LinearElementEditor {
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
mutateElement(element, {
...otherUpdates,
points: nextPoints,
@@ -1264,207 +1170,6 @@ export class LinearElementEditor {
return rotatePoint([width, height], [0, 0], -element.angle);
}
static getBoundTextElementPosition = (
element: ExcalidrawLinearElement,
boundTextElement: ExcalidrawTextElementWithContainer,
): { x: number; y: number } => {
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
if (points.length < 2) {
mutateElement(boundTextElement, { isDeleted: true });
}
let x = 0;
let y = 0;
if (element.points.length % 2 === 1) {
const index = Math.floor(element.points.length / 2);
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[index],
);
x = midPoint[0] - boundTextElement.width / 2;
y = midPoint[1] - boundTextElement.height / 2;
} else {
const index = element.points.length / 2 - 1;
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
midSegmentMidpoint = centerPoint(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
editorMidPointsCache.version !== element.version
) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
);
}
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
}
return { x, y };
};
static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement,
elementBounds: [number, number, number, number],
boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const { x: boundTextX1, y: boundTextY1 } =
LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
);
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
const counterRotateBoundTextTopLeft = rotatePoint(
[boundTextX1, boundTextY1],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextTopRight = rotatePoint(
[boundTextX2, boundTextY1],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextBottomLeft = rotatePoint(
[boundTextX1, boundTextY2],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextBottomRight = rotatePoint(
[boundTextX2, boundTextY2],
[cx, cy],
-element.angle,
);
if (
topLeftRotatedPoint[0] < topRightRotatedPoint[0] &&
topLeftRotatedPoint[1] >= topRightRotatedPoint[1]
) {
x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]);
x2 = Math.max(
x2,
Math.max(
counterRotateBoundTextTopRight[0],
counterRotateBoundTextBottomRight[0],
),
);
y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]);
y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]);
} else if (
topLeftRotatedPoint[0] >= topRightRotatedPoint[0] &&
topLeftRotatedPoint[1] > topRightRotatedPoint[1]
) {
x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]);
x2 = Math.max(
x2,
Math.max(
counterRotateBoundTextTopLeft[0],
counterRotateBoundTextTopRight[0],
),
);
y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]);
y2 = Math.max(y2, counterRotateBoundTextTopRight[1]);
} else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) {
x1 = Math.min(x1, counterRotateBoundTextTopRight[0]);
x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]);
y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]);
y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]);
} else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) {
x1 = Math.min(
x1,
Math.min(
counterRotateBoundTextTopRight[0],
counterRotateBoundTextTopLeft[0],
),
);
x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]);
y1 = Math.min(y1, counterRotateBoundTextTopRight[1]);
y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]);
}
return [x1, y1, x2, y2, cx, cy];
};
static getElementAbsoluteCoords = (
element: ExcalidrawLinearElement,
includeBoundText: boolean = false,
): [number, number, number, number, number, number] => {
let coords: [number, number, number, number, number, number];
let x1;
let y1;
let x2;
let y2;
if (element.points.length < 2 || !getShapeForElement(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, y);
return limits;
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
x1 = minX + element.x;
y1 = minY + element.y;
x2 = maxX + element.x;
y2 = maxY + element.y;
} else {
const shape = getShapeForElement(element)!;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
x1 = minX + element.x;
y1 = minY + element.y;
x2 = maxX + element.x;
y2 = maxY + element.y;
}
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
coords = [x1, y1, x2, y2, cx, cy];
if (!includeBoundText) {
return coords;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
coords = LinearElementEditor.getMinMaxXYWithBoundText(
element,
[x1, y1, x2, y2],
boundTextElement,
);
}
return coords;
};
}
const normalizeSelectedPoints = (

View File

@@ -1,7 +1,7 @@
import { duplicateElement } from "./newElement";
import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { FONT_FAMILY } from "../constants";
import { isPrimitive } from "../utils";
const assertCloneObjects = (source: any, clone: any) => {
@@ -25,7 +25,7 @@ it("clones arrow element", () => {
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
strokeSharpness: "round",
roughness: 1,
opacity: 100,
});
@@ -71,7 +71,7 @@ it("clones text element", () => {
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
strokeSharpness: "round",
roughness: 1,
opacity: 100,
text: "hello",

View File

@@ -11,7 +11,7 @@ import {
Arrowhead,
ExcalidrawFreeDrawElement,
FontFamilyValues,
ExcalidrawTextContainer,
ExcalidrawRectangleElement,
} from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
@@ -22,16 +22,12 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
getBoundTextElement,
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
measureText,
normalizeText,
wrapText,
} from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks";
type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -62,15 +58,14 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0,
angle = 0,
groupIds = [],
roundness = null,
strokeSharpness,
boundElements = null,
link = null,
locked,
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
const element = {
id: rest.id || randomId(),
type,
x,
@@ -86,7 +81,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roughness,
opacity,
groupIds,
roundness,
strokeSharpness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,
@@ -135,16 +130,15 @@ export const newTextElement = (
fontFamily: FontFamilyValues;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"];
containerId?: ExcalidrawRectangleElement["id"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts));
const metrics = measureText(opts.text, getFontString(opts));
const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith(
{
..._newElementBase<ExcalidrawTextElement>("text", opts),
text,
text: opts.text,
fontSize: opts.fontSize,
fontFamily: opts.fontFamily,
textAlign: opts.textAlign,
@@ -155,7 +149,7 @@ export const newTextElement = (
height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
originalText: opts.text,
},
{},
);
@@ -235,21 +229,16 @@ const getAdjustedDimensions = (
// make sure container dimensions are set properly when
// text editor overflows beyond viewport dimensions
if (container) {
const boundTextElementPadding = getBoundTextElementOffset(element);
const containerDims = getContainerDims(container);
let height = containerDims.height;
let width = containerDims.width;
if (nextHeight > height - boundTextElementPadding * 2) {
height = nextHeight + boundTextElementPadding * 2;
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
height = nextHeight + BOUND_TEXT_PADDING * 2;
}
if (nextWidth > width - boundTextElementPadding * 2) {
width = nextWidth + boundTextElementPadding * 2;
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
width = nextWidth + BOUND_TEXT_PADDING * 2;
}
if (
!isArrowElement(container) &&
(height !== containerDims.height || width !== containerDims.width)
) {
if (height !== containerDims.height || width !== containerDims.width) {
mutateElement(container, { height, width });
}
}
@@ -279,35 +268,11 @@ export const refreshTextDimensions = (
};
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
return width - BOUND_TEXT_PADDING * 2;
return getContainerDims(container).width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
return height - BOUND_TEXT_PADDING * 2;
return getContainerDims(container).height - BOUND_TEXT_PADDING * 2;
};
export const updateTextElement = (
@@ -399,8 +364,7 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
: {};
for (const key in val) {
if (val.hasOwnProperty(key)) {
// don't copy non-serializable objects like these caches. They'll be
// populated when the element is rendered.
// don't copy top-level shape property, which we want to regenerate
if (depth === 0 && (key === "shape" || key === "canvas")) {
continue;
}
@@ -443,7 +407,6 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
overrides?: Partial<TElement>,
): TElement => {
let copy: TElement = deepCopyElement(element);
if (isTestEnv()) {
copy.id = `${copy.id}_copy`;
// `window.h` may not be defined in some unit tests
@@ -457,7 +420,6 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
} else {
copy.id = randomId();
}
copy.boundElements = null;
copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger();
copy.groupIds = getNewGroupIdsForDuplication(

View File

@@ -1,4 +1,4 @@
import { SHIFT_LOCKING_ANGLE } from "../constants";
import { BOUND_TEXT_PADDING, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import {
@@ -13,7 +13,6 @@ import {
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawElement,
ExcalidrawTextElementWithContainer,
} from "./types";
import {
getElementAbsoluteCoords,
@@ -22,13 +21,12 @@ import {
getCommonBoundingBox,
} from "./bounds";
import {
isArrowElement,
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import {
@@ -43,12 +41,9 @@ import {
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
getBoundTextElementOffset,
getContainerElement,
handleBindTextResize,
measureText,
} from "./textElement";
import { getMaxContainerWidth } from "./newElement";
export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
@@ -79,9 +74,23 @@ export const transformElements = (
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
pointerDownState.originalElements,
);
updateBoundElements(element);
} else if (
isLinearElement(element) &&
element.points.length === 2 &&
(transformHandleType === "nw" ||
transformHandleType === "ne" ||
transformHandleType === "sw" ||
transformHandleType === "se")
) {
reshapeSingleTwoPointElement(
element,
resizeArrowDirection,
shouldRotateWithDiscreteAngle,
pointerX,
pointerY,
);
} else if (
isTextElement(element) &&
(transformHandleType === "nw" ||
@@ -147,7 +156,6 @@ const rotateSingleElement = (
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
@@ -158,21 +166,100 @@ const rotateSingleElement = (
angle -= angle % SHIFT_LOCKING_ANGLE;
}
angle = normalizeAngle(angle);
const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle });
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement =
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
boundTextElementId,
);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, { angle });
}
const textElement = Scene.getScene(element)!.getElement(boundTextElementId);
mutateElement(textElement!, { angle });
}
};
// used in DEV only
const validateTwoPointElementNormalized = (
element: NonDeleted<ExcalidrawLinearElement>,
) => {
if (
element.points.length !== 2 ||
element.points[0][0] !== 0 ||
element.points[0][1] !== 0 ||
Math.abs(element.points[1][0]) !== element.width ||
Math.abs(element.points[1][1]) !== element.height
) {
throw new Error("Two-point element is not normalized");
}
};
const getPerfectElementSizeWithRotation = (
elementType: ExcalidrawElement["type"],
width: number,
height: number,
angle: number,
): [number, number] => {
const size = getPerfectElementSize(
elementType,
...rotate(width, height, 0, 0, angle),
);
return rotate(size.width, size.height, 0, 0, -angle);
};
export const reshapeSingleTwoPointElement = (
element: NonDeleted<ExcalidrawLinearElement>,
resizeArrowDirection: "origin" | "end",
shouldRotateWithDiscreteAngle: boolean,
pointerX: number,
pointerY: number,
) => {
if (process.env.NODE_ENV !== "production") {
validateTwoPointElementNormalized(element);
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
pointerY,
cx,
cy,
-element.angle,
);
let [width, height] =
resizeArrowDirection === "end"
? [rotatedX - element.x, rotatedY - element.y]
: [
element.x + element.points[1][0] - rotatedX,
element.y + element.points[1][1] - rotatedY,
];
if (shouldRotateWithDiscreteAngle) {
[width, height] = getPerfectElementSizeWithRotation(
element.type,
width,
height,
element.angle,
);
}
const [nextElementX, nextElementY] = adjustXYWithRotation(
resizeArrowDirection === "end"
? { s: true, e: true }
: { n: true, w: true },
element.x,
element.y,
element.angle,
0,
0,
(element.points[1][0] - width) / 2,
(element.points[1][1] - height) / 2,
);
mutateElement(element, {
x: nextElementX,
y: nextElementY,
points: [
[0, 0],
[width, height],
],
});
};
const rescalePointsInElement = (
element: NonDeletedExcalidrawElement,
width: number,
@@ -198,23 +285,14 @@ const measureFontSizeFromWH = (
nextHeight: number,
): { size: number; baseline: number } | null => {
// We only use width to scale font on resize
let width = element.width;
const hasContainer = isBoundToContainer(element);
if (hasContainer) {
const container = getContainerElement(element);
if (container) {
width = getMaxContainerWidth(container);
}
}
const nextFontSize = element.fontSize * (nextWidth / width);
const nextFontSize = element.fontSize * (nextWidth / element.width);
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.containerId ? width : null,
element.containerId ? element.width : null,
);
return {
size: nextFontSize,
@@ -427,12 +505,10 @@ export const resizeSingleElement = (
};
}
if (shouldMaintainAspectRatio) {
const boundTextElementPadding =
getBoundTextElementOffset(boundTextElement);
const nextFont = measureFontSizeFromWH(
boundTextElement,
eleNewWidth - boundTextElementPadding * 2,
eleNewHeight - boundTextElementPadding * 2,
eleNewWidth - BOUND_TEXT_PADDING * 2,
eleNewHeight - BOUND_TEXT_PADDING * 2,
);
if (nextFont === null) {
return;
@@ -521,36 +597,24 @@ export const resizeSingleElement = (
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
// Readjust points for linear elements
let rescaledElementPointsY;
let rescaledPoints;
if (isLinearElement(element) || isFreeDrawElement(element)) {
rescaledElementPointsY = rescalePoints(
1,
eleNewHeight,
(stateAtResizeStart as ExcalidrawLinearElement).points,
true,
);
rescaledPoints = rescalePoints(
0,
eleNewWidth,
rescaledElementPointsY,
true,
);
}
const rescaledPoints = rescalePointsInElement(
stateAtResizeStart,
eleNewWidth,
eleNewHeight,
true,
);
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
const resizedElement = {
width: Math.abs(eleNewWidth),
height: Math.abs(eleNewHeight),
x: newOrigin[0],
y: newOrigin[1],
points: rescaledPoints,
...rescaledPoints,
};
if ("scale" in element && "scale" in stateAtResizeStart) {
@@ -574,7 +638,6 @@ export const resizeSingleElement = (
updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height },
});
mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
@@ -697,7 +760,7 @@ const resizeMultipleElements = (
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
const textMeasurements = measureFontSizeFromWH(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding,
@@ -727,7 +790,6 @@ const resizeMultipleElements = (
if (boundTextElement && boundTextUpdates) {
mutateElement(boundTextElement, boundTextUpdates);
handleBindTextResize(element.latest, transformHandleType);
}
});
@@ -748,7 +810,7 @@ const rotateMultipleElements = (
centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}
elements.forEach((element) => {
elements.forEach((element, index) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
@@ -769,16 +831,12 @@ const rotateMultipleElements = (
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement =
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
boundTextElementId,
);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, {
x: textElement.x + (rotatedCX - cx),
y: textElement.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
});
}
Scene.getScene(element)!.getElement(boundTextElementId)!;
mutateElement(textElement, {
x: textElement.x + (rotatedCX - cx),
y: textElement.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
});
}
});
};

View File

@@ -94,7 +94,7 @@ export const getTransformHandleTypeFromCoords = (
pointerType: PointerType,
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
[x1, y1, x2, y2],
0,
zoom,
pointerType,

View File

@@ -1,17 +1,10 @@
import { BOUND_TEXT_PADDING } from "../constants";
import { measureText, wrapText } from "./textElement";
import { wrapText } from "./textElement";
import { FontString } from "./types";
describe("Test wrapText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello whats up ");
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
@@ -146,37 +139,3 @@ break it now`,
});
});
});
describe("Test measureText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
const text = "Hello World";
it("should add correct attributes when maxWidth is passed", () => {
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = measureText(text, font, maxWidth);
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; width: 111px; overflow: hidden; word-break: break-word; line-height: 0px;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
});
it("should add correct attributes when maxWidth is not passed", () => {
const res = measureText(text, font);
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
});
});

View File

@@ -1,7 +1,6 @@
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontString,
@@ -13,31 +12,6 @@ import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene";
import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import {
isBoundToContainer,
isImageElement,
isArrowElement,
} from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
import { AppState } from "../types";
import { isTextBindableContainer } from "./typeChecks";
import { getElementAbsoluteCoords } from "../element";
import { getSelectedElements } from "../scene";
import { isHittingElementNotConsideringBoundingBox } from "./collision";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./textWysiwyg";
export const normalizeText = (text: string) => {
return (
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
// normalize newlines
.replace(/\r?\n|\r/g, "\n")
);
};
export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
@@ -53,52 +27,45 @@ export const redrawTextBoundingBox = (
maxWidth,
);
}
const metrics = measureText(text, getFontString(textElement), maxWidth);
const metrics = measureText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
let coordY = textElement.y;
let coordX = textElement.x;
// Resize container and vertically center align the text
if (container) {
if (!isArrowElement(container)) {
const containerDims = getContainerDims(container);
let nextHeight = containerDims.height;
const boundTextElementPadding = getBoundTextElementOffset(textElement);
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y + boundTextElementPadding;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
metrics.height -
boundTextElementPadding;
} else {
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
if (metrics.height > getMaxContainerHeight(container)) {
nextHeight = metrics.height + boundTextElementPadding * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2;
}
}
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
coordX = container.x + boundTextElementPadding;
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX =
container.x +
containerDims.width -
metrics.width -
boundTextElementPadding;
} else {
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
}
updateOriginalContainerCache(container.id, nextHeight);
mutateElement(container, { height: nextHeight });
const containerDims = getContainerDims(container);
let nextHeight = containerDims.height;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y + BOUND_TEXT_PADDING;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
metrics.height -
BOUND_TEXT_PADDING;
} else {
const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height;
coordY = centerY - (textElement.height + diffHeight) / 2;
coordX = centerX - (textElement.width + diffWidth) / 2;
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
if (metrics.height > getMaxContainerHeight(container)) {
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2;
}
}
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
coordX = container.x + BOUND_TEXT_PADDING;
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX =
container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING;
} else {
coordX = container.x + container.width / 2 - metrics.width / 2;
}
mutateElement(container, { height: nextHeight });
}
mutateElement(textElement, {
width: metrics.width,
height: metrics.height,
@@ -128,7 +95,7 @@ export const bindTextToShapeAfterDuplication = (
const newContainer = sceneElementMap.get(newElementId);
if (newContainer) {
mutateElement(newContainer, {
boundElements: (newContainer.boundElements || []).concat({
boundElements: element.boundElements?.concat({
type: "text",
id: newTextElementId,
}),
@@ -146,114 +113,84 @@ export const bindTextToShapeAfterDuplication = (
};
export const handleBindTextResize = (
container: NonDeletedExcalidrawElement,
element: NonDeletedExcalidrawElement,
transformHandleType: MaybeTransformHandleType,
) => {
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) {
return;
}
resetOriginalContainerCache(container.id);
let textElement = Scene.getScene(container)!.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
if (textElement && textElement.text) {
if (!container) {
return;
}
textElement = Scene.getScene(container)!.getElement(
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement = Scene.getScene(element)!.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
let text = textElement.text;
let nextHeight = textElement.height;
let nextWidth = textElement.width;
const containerDims = getContainerDims(container);
const maxWidth = getMaxContainerWidth(container);
const maxHeight = getMaxContainerHeight(container);
let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
if (textElement && textElement.text) {
if (!element) {
return;
}
const dimensions = measureText(
text,
getFontString(textElement),
maxWidth,
);
nextHeight = dimensions.height;
nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
!isArrowElement(container) &&
(transformHandleType === "ne" ||
let text = textElement.text;
let nextHeight = textElement.height;
let nextWidth = textElement.width;
let containerHeight = element.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
getMaxContainerWidth(element),
);
}
const dimensions = measureText(
text,
getFontString(textElement),
element.width,
);
nextHeight = dimensions.height;
nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
const diff = containerHeight - element.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
transformHandleType === "ne" ||
transformHandleType === "nw" ||
transformHandleType === "n")
? container.y - diff
: container.y;
mutateElement(container, {
height: containerHeight,
transformHandleType === "n"
? element.y - diff
: element.y;
mutateElement(element, {
height: containerHeight,
y: updatedY,
});
}
let updatedY;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
updatedY = element.y + BOUND_TEXT_PADDING;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
} else {
updatedY = element.y + element.height / 2 - nextHeight / 2;
}
const updatedX =
textElement.textAlign === TEXT_ALIGN.LEFT
? element.x + BOUND_TEXT_PADDING
: textElement.textAlign === TEXT_ALIGN.RIGHT
? element.x + element.width - nextWidth - BOUND_TEXT_PADDING
: element.x + element.width / 2 - nextWidth / 2;
mutateElement(textElement, {
text,
width: nextWidth,
height: nextHeight,
x: updatedX,
y: updatedY,
baseline: nextBaseLine,
});
}
mutateElement(textElement, {
text,
width: nextWidth,
height: nextHeight,
baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
updateBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
);
}
}
};
const updateBoundTextPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const containerDims = getContainerDims(container);
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = container.y + boundTextElementPadding;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y =
container.y +
containerDims.height -
boundTextElement.height -
boundTextElementPadding;
} else {
y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
}
const x =
boundTextElement.textAlign === TEXT_ALIGN.LEFT
? container.x + boundTextElementPadding
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT
? container.x +
containerDims.width -
boundTextElement.width -
boundTextElementPadding
: container.x + containerDims.width / 2 - boundTextElement.width / 2;
mutateElement(boundTextElement, { x, y });
};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string,
@@ -271,12 +208,9 @@ export const measureText = (
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
const textWidth = getTextWidth(text, font);
if (maxWidth) {
const lineHeight = getApproxLineHeight(font);
container.style.width = `${String(Math.min(textWidth, maxWidth) + 1)}px`;
container.style.maxWidth = `${String(maxWidth)}px`;
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`;
@@ -294,15 +228,10 @@ export const measureText = (
// Baseline is important for positioning text on canvas
const baseline = span.offsetTop + span.offsetHeight;
// Since span adds 1px extra width to the container
let width = container.offsetWidth;
if (maxWidth && textWidth > maxWidth) {
width = width - 1;
}
const width = container.offsetWidth + 1;
const height = container.offsetHeight;
document.body.removeChild(container);
if (isTestEnv()) {
return { width, height, baseline, container };
}
return { width, height, baseline };
};
@@ -318,7 +247,7 @@ export const getApproxLineHeight = (font: FontString) => {
};
let canvas: HTMLCanvasElement | undefined;
const getLineWidth = (text: string, font: FontString) => {
const getTextWidth = (text: string, font: FontString) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
@@ -336,24 +265,10 @@ const getLineWidth = (text: string, font: FontString) => {
return metrics.width;
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = text.split("\n");
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font);
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
const spaceWidth = getTextWidth(" ", font);
originalLines.forEach((originalLine) => {
const words = originalLine.split(" ");
// This means its newline so push it
@@ -365,13 +280,15 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font);
const currentWordWidth = getTextWidth(words[index], font);
// Start breaking longer words exceeding max width
if (currentWordWidth >= maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
if (currentLine) {
lines.push(currentLine);
}
currentLine = "";
currentLineWidthTillNow = 0;
while (words[index].length > 0) {
@@ -385,7 +302,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
lines.push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
if (currentLineWidthTillNow === maxWidth) {
@@ -398,7 +315,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
push(currentLine);
lines.push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
} else {
@@ -414,10 +331,10 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
currentLineWidthTillNow = getTextWidth(currentLine + word, font);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
lines.push(currentLine);
currentLineWidthTillNow = 0;
currentLine = "";
@@ -428,8 +345,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
const word = currentLine.slice(0, -1);
push(word);
lines.push(currentLine.slice(0, -1));
currentLine = "";
currentLineWidthTillNow = 0;
break;
@@ -446,7 +362,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
lines.push(currentLine);
}
}
});
@@ -462,7 +378,7 @@ export const charWidth = (() => {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font);
const width = getTextWidth(char, font);
cachedCharWidth[font][ascii] = width;
}
@@ -479,7 +395,6 @@ export const charWidth = (() => {
})();
export const getApproxMinLineWidth = (font: FontString) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
@@ -522,7 +437,7 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
while (widthTillNow <= width) {
const batch = dummyText.substr(index, index + batchLength);
str += batch;
widthTillNow += getLineWidth(str, font);
widthTillNow += getTextWidth(str, font);
if (index === dummyText.length - 1) {
index = 0;
}
@@ -531,7 +446,7 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
while (widthTillNow > width) {
str = str.substr(0, str.length - 1);
widthTillNow = getLineWidth(str, font);
widthTillNow = getTextWidth(str, font);
}
return str.length;
};
@@ -560,9 +475,7 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
export const getContainerElement = (
element:
| (ExcalidrawElement & {
containerId: ExcalidrawElement["id"] | null;
})
| (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
| null,
) => {
if (!element) {
@@ -575,149 +488,5 @@ export const getContainerElement = (
};
export const getContainerDims = (element: ExcalidrawElement) => {
const MIN_WIDTH = 300;
if (isArrowElement(element)) {
const width = Math.max(element.width, MIN_WIDTH);
const height = element.height;
return { width, height };
}
return { width: element.width, height: element.height };
};
export const getContainerCenter = (
container: ExcalidrawElement,
appState: AppState,
) => {
if (!isArrowElement(container)) {
return {
x: container.x + container.width / 2,
y: container.y + container.height / 2,
};
}
const points = LinearElementEditor.getPointsGlobalCoordinates(container);
if (points.length % 2 === 1) {
const index = Math.floor(container.points.length / 2);
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
container,
container.points[index],
);
return { x: midPoint[0], y: midPoint[1] };
}
const index = container.points.length / 2 - 1;
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
container,
appState,
)[index];
if (!midSegmentMidpoint) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
points[index],
points[index + 1],
index + 1,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
};
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
const container = getContainerElement(textElement);
if (!container || isArrowElement(container)) {
return textElement.angle;
}
return container.angle;
};
export const getBoundTextElementOffset = (
boundTextElement: ExcalidrawTextElement | null,
) => {
const container = getContainerElement(boundTextElement);
if (!container) {
return 0;
}
if (isArrowElement(container)) {
return BOUND_TEXT_PADDING * 8;
}
return BOUND_TEXT_PADDING;
};
export const getBoundTextElementPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
if (isArrowElement(container)) {
return LinearElementEditor.getBoundTextElementPosition(
container,
boundTextElement,
);
}
};
export const shouldAllowVerticalAlign = (
selectedElements: NonDeletedExcalidrawElement[],
) => {
return selectedElements.some((element) => {
const hasBoundContainer = isBoundToContainer(element);
if (hasBoundContainer) {
const container = getContainerElement(element);
if (isTextElement(element) && isArrowElement(container)) {
return false;
}
return true;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
if (isArrowElement(element)) {
return false;
}
return true;
}
return false;
});
};
export const getTextBindableContainerAtPosition = (
elements: readonly ExcalidrawElement[],
appState: AppState,
x: number,
y: number,
): ExcalidrawTextContainer | null => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1) {
return isTextBindableContainer(selectedElements[0], false)
? selectedElements[0]
: null;
}
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let index = elements.length - 1; index >= 0; --index) {
if (elements[index].isDeleted) {
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
if (
isArrowElement(elements[index]) &&
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
x,
y,
])
) {
hitElement = elements[index];
break;
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[index];
break;
}
}
return isTextBindableContainer(hitElement, false) ? hitElement : null;
};
export const isValidTextContainer = (element: ExcalidrawElement) => {
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond" ||
isImageElement(element) ||
isArrowElement(element)
);
};

View File

@@ -10,12 +10,13 @@ import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import {
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontString,
} from "./types";
import * as textElementUtils from "./textElement";
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
import { getMaxContainerWidth } from "./newElement";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -434,6 +435,25 @@ describe("textWysiwyg", () => {
);
expect(h.state.zoom.value).toBe(1);
});
it("should paste text correctly", async () => {
Keyboard.keyPress(KEYS.ENTER);
await new Promise((r) => setTimeout(r, 0));
const text = "A quick brown fox jumps over the lazy dog.";
//@ts-ignore
textarea.onpaste({
preventDefault: () => {},
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
await new Promise((cb) => setTimeout(cb, 0));
textarea.blur();
expect(textElement.text).toBe(text);
});
});
describe("Test container-bound text", () => {
@@ -463,7 +483,7 @@ describe("textWysiwyg", () => {
});
});
it("should bind text to container when double clicked on center of filled container", async () => {
it("should bind text to container when double clicked on center", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
@@ -473,43 +493,6 @@ describe("textWysiwyg", () => {
);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
it("should bind text to container when double clicked on center of transparent container", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "transparent",
});
h.elements = [rectangle];
mouse.doubleClickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
@@ -552,19 +535,20 @@ describe("textWysiwyg", () => {
});
it("shouldn't bind to non-text-bindable containers", async () => {
const freedraw = API.createElement({
type: "freedraw",
const line = API.createElement({
type: "line",
width: 100,
height: 0,
points: [
[0, 0],
[100, 0],
],
});
h.elements = [freedraw];
h.elements = [line];
UI.clickTool("text");
mouse.clickAt(
freedraw.x + freedraw.width / 2,
freedraw.y + freedraw.height / 2,
);
mouse.clickAt(line.x + line.width / 2, line.y + line.height / 2);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
@@ -578,24 +562,11 @@ describe("textWysiwyg", () => {
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
editor.dispatchEvent(new Event("input"));
expect(freedraw.boundElements).toBe(null);
expect(line.boundElements).toBe(null);
expect(h.elements[1].type).toBe("text");
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
});
["freedraw", "line"].forEach((type: any) => {
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
h.elements = [];
const elemnet = UI.createElement(type, {
width: 100,
height: 50,
});
API.setSelectedElements([elemnet]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(1);
});
});
it("should'nt bind text to container when not double clicked on center", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
@@ -826,9 +797,9 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(2);
// Bind first text
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = document.querySelector(
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
@@ -838,14 +809,25 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(2);
// Attempt to bind another text
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.down();
expect(h.elements.length).toBe(3);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Whats up?" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
expect(text.containerId).toBe(rectangle.id);
expect(text.containerId).toBe(null);
});
it("should respect text alignment when resizing", async () => {
@@ -862,7 +844,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
110,
109.5,
17,
]
`);
@@ -910,260 +892,73 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
425,
424,
-539,
]
`);
});
it("should always bind to selected container and insert it in correct position", async () => {
const rectangle2 = UI.createElement("rectangle", {
x: 5,
y: 10,
width: 120,
height: 100,
});
API.setSelectedElements([rectangle]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe("text");
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle2.boundElements).toBeNull();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(rectangle.width).toBe(90);
expect(rectangle.height).toBe(75);
expect(textElement.fontSize).toBe(20);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
shift: true,
});
expect(rectangle.width).toBe(200);
expect(rectangle.height).toBe(166.66666666666669);
expect(textElement.fontSize).toBe(47.5);
});
it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
expect(h.elements.length).toBe(2);
mouse.select(rectangle);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 10, rectangle.y + 10);
mouse.up(rectangle.x + 10, rectangle.y + 10);
});
expect(h.elements.length).toBe(4);
const duplicatedRectangle = h.elements[0];
const duplicatedText = h
.elements[1] as ExcalidrawTextElementWithContainer;
const originalRect = h.elements[2];
const originalText = h.elements[3] as ExcalidrawTextElementWithContainer;
expect(originalRect.boundElements).toStrictEqual([
{ id: originalText.id, type: "text" },
]);
expect(originalText.containerId).toBe(originalRect.id);
expect(duplicatedRectangle.boundElements).toStrictEqual([
{ id: duplicatedText.id, type: "text" },
]);
expect(duplicatedText.containerId).toBe(duplicatedRectangle.id);
});
it("undo should work", async () => {
it("should compute the dimensions correctly when text pasted", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
const originalRectX = rectangle.x;
const originalRectY = rectangle.y;
const originalTextX = text.x;
const originalTextY = text.y;
mouse.select(rectangle);
mouse.downAt(rectangle.x, rectangle.y);
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(85);
expect(text.x).toBe(90);
expect(text.y).toBe(90);
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(rectangle.x).toBe(originalRectX);
expect(rectangle.y).toBe(originalRectY);
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.x).toBe(originalTextX);
expect(text.y).toBe(originalTextY);
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
expect(text.containerId).toBe(rectangle.id);
});
const wrappedText = textElementUtils.wrapText(
"Wikipedia is hosted by the Wikimedia Foundation, a non-profit organization that also hosts a range of other projects.",
font,
getMaxContainerWidth(rectangle),
);
it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: " " } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true);
});
it("should restore original container height and clear cache once text is unbind", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
if (text === wrappedText) {
return { width: rectangle.width, height: 200, baseline: 30 };
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
return {
width,
height,
baseline,
};
return { width: 0, height: 0, baseline: 0 };
});
const originalRectHeight = rectangle.height;
expect(rectangle.height).toBe(originalRectHeight);
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, {
target: { value: "Online whiteboard collaboration made easy" },
//@ts-ignore
editor.onpaste({
preventDefault: () => {},
//@ts-ignore
clipboardData: {
getData: () =>
"Wikipedia is hosted by the Wikimedia Foundation, a non-profit organization that also hosts a range of other projects.",
},
});
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
expect(rectangle.height).toBe(135);
mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 20,
clientY: 30,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
expect(h.elements[0].boundElements).toEqual([]);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
expect(rectangle.height).toBe(originalRectHeight);
});
it("should reset the container height cache when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(215);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.height).toBe(215);
// cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
});
//@todo fix this test later once measureText is mocked correctly
it.skip("should reset the container height cache when font properties updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
fireEvent.click(screen.getByTitle(/Very large/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
expect(rectangle.width).toBe(100);
expect(rectangle.height).toBe(210);
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.text).toMatchInlineSnapshot(
`
"Wikipedi
a is
hosted
by the
Wikimedi
a
Foundati
on, a
non-prof
it
organiza
tion
that
also
hosts a
range of
other
projects
."
`,
);
});
});
});

View File

@@ -6,30 +6,21 @@ import {
isTestEnv,
} from "../utils";
import Scene from "../scene/Scene";
import {
isArrowElement,
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, VERTICAL_ALIGN } from "../constants";
import { isBoundToContainer, isTextElement } from "./typeChecks";
import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer,
ExcalidrawTextElement,
ExcalidrawTextContainer,
ExcalidrawLinearElement,
} from "./types";
import { AppState } from "../types";
import { mutateElement } from "./mutateElement";
import {
getApproxLineHeight,
getBoundTextElementId,
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
getTextElementAngle,
getTextWidth,
normalizeText,
measureText,
wrapText,
} from "./textElement";
import {
@@ -38,10 +29,19 @@ import {
} from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { LinearElementEditor } from "./linearElementEditor";
import { getMaxContainerWidth } from "./newElement";
import { parseClipboard } from "../clipboard";
const normalizeText = (text: string) => {
return (
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
// normalize newlines
.replace(/\r?\n|\r/g, "\n")
);
};
const getTransform = (
width: number,
height: number,
@@ -63,38 +63,6 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
};
const originalContainerCache: {
[id: ExcalidrawTextContainer["id"]]:
| {
height: ExcalidrawTextContainer["height"];
}
| undefined;
} = {};
export const updateOriginalContainerCache = (
id: ExcalidrawTextContainer["id"],
height: ExcalidrawTextContainer["height"],
) => {
const data =
originalContainerCache[id] || (originalContainerCache[id] = { height });
data.height = height;
return data;
};
export const resetOriginalContainerCache = (
id: ExcalidrawTextContainer["id"],
) => {
if (originalContainerCache[id]) {
delete originalContainerCache[id];
}
};
export const getOriginalContainerHeightFromCache = (
id: ExcalidrawTextContainer["id"],
) => {
return originalContainerCache[id]?.height ?? null;
};
export const textWysiwyg = ({
id,
onChange,
@@ -122,9 +90,6 @@ export const textWysiwyg = ({
updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement,
) => {
if (!editable.style.fontFamily || !editable.style.fontSize) {
return false;
}
const currentFont = editable.style.fontFamily.replace(/"/g, "");
if (
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
@@ -137,6 +102,7 @@ export const textWysiwyg = ({
}
return false;
};
let originalContainerHeight: number;
const updateWysiwygStyle = () => {
const appState = app.state;
@@ -151,7 +117,7 @@ export const textWysiwyg = ({
getFontString(updatedTextElement),
);
if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x;
const coordX = updatedTextElement.x;
let coordY = updatedTextElement.y;
const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
@@ -160,17 +126,8 @@ export const textWysiwyg = ({
const width = updatedTextElement.width;
// Set to element height by default since that's
// what is going to be used for unbounded text
let textElementHeight = updatedTextElement.height;
let height = updatedTextElement.height;
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
const boundTextCoords =
LinearElementEditor.getBoundTextElementPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
coordX = boundTextCoords.x;
coordY = boundTextCoords.y;
}
const propertiesUpdated = textPropertiesUpdated(
updatedTextElement,
editable,
@@ -179,52 +136,31 @@ export const textWysiwyg = ({
// using editor.style.height to get the accurate height of text editor
const editorHeight = Number(editable.style.height.slice(0, -2));
if (editorHeight > 0) {
textElementHeight = editorHeight;
height = editorHeight;
}
if (propertiesUpdated) {
originalContainerHeight = containerDims.height;
// update height of the editor after properties updated
textElementHeight = updatedTextElement.height;
height = updatedTextElement.height;
}
let originalContainerData;
if (propertiesUpdated) {
originalContainerData = updateOriginalContainerCache(
container.id,
containerDims.height,
);
} else {
originalContainerData = originalContainerCache[container.id];
if (!originalContainerData) {
originalContainerData = updateOriginalContainerCache(
container.id,
containerDims.height,
);
}
if (!originalContainerHeight) {
originalContainerHeight = containerDims.height;
}
maxWidth = getMaxContainerWidth(container);
maxHeight = getMaxContainerHeight(container);
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
// autogrow container height if text exceeds
if (!isArrowElement(container) && textElementHeight > maxHeight) {
const diff = Math.min(
textElementHeight - maxHeight,
approxLineHeight,
);
if (height > maxHeight) {
const diff = Math.min(height - maxHeight, approxLineHeight);
mutateElement(container, { height: containerDims.height + diff });
return;
} else if (
// autoshrink container height until original container height
// is reached when text is removed
!isArrowElement(container) &&
containerDims.height > originalContainerData.height &&
textElementHeight < maxHeight
containerDims.height > originalContainerHeight &&
height < maxHeight
) {
const diff = Math.min(
maxHeight - textElementHeight,
approxLineHeight,
);
const diff = Math.min(maxHeight - height, approxLineHeight);
mutateElement(container, { height: containerDims.height - diff });
}
// Start pushing text upward until a diff of 30px (padding)
@@ -232,17 +168,11 @@ export const textWysiwyg = ({
else {
// vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
if (!isArrowElement(container)) {
coordY =
container.y + containerDims.height / 2 - textElementHeight / 2;
}
coordY = container.y + containerDims.height / 2 - height / 2;
}
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
container.y + containerDims.height - height - BOUND_TEXT_PADDING;
}
}
}
@@ -276,19 +206,19 @@ export const textWysiwyg = ({
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
const angle = container ? container.angle : updatedTextElement.angle;
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`,
width: `${Math.min(width, maxWidth)}px`,
height: `${textElementHeight}px`,
height: `${height}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
transform: getTransform(
width,
textElementHeight,
getTextElementAngle(updatedTextElement),
height,
angle,
appState,
maxWidth,
editorMaxHeight,
@@ -343,37 +273,39 @@ export const textWysiwyg = ({
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace,
overflowWrap: "break-word",
boxSizing: "content-box",
});
updateWysiwygStyle();
if (onChange) {
editable.onpaste = async (event) => {
const clipboardData = await parseClipboard(event, true);
event.preventDefault();
const clipboardData = await parseClipboard(event);
if (!clipboardData.text) {
return;
}
const data = normalizeText(clipboardData.text);
if (!data) {
return;
}
const container = getContainerElement(element);
const font = getFontString({
fontSize: app.state.currentItemFontSize,
fontFamily: app.state.currentItemFontFamily,
});
if (container) {
const wrappedText = wrapText(
`${editable.value}${data}`,
font,
getMaxContainerWidth(container),
);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
if (data) {
const text = editable.value;
const start = Math.min(editable.selectionStart, editable.selectionEnd);
const end = Math.max(editable.selectionStart, editable.selectionEnd);
const newText = `${text.substring(0, start)}${data}${text.substring(
end,
)}`;
const wrappedText = container
? wrapText(newText, font, getMaxContainerWidth(container!))
: newText;
const dimensions = measureText(wrappedText, font);
editable.style.height = `${dimensions.height}px`;
onChange(newText);
}
};
editable.oninput = () => {
const updatedTextElement = Scene.getScene(element)?.getElement(
id,
@@ -584,7 +516,7 @@ export const textWysiwyg = ({
if (container) {
text = updateElement.text;
if (editable.value.trim()) {
if (editable.value) {
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId || boundTextElementId !== element.id) {
mutateElement(container, {

View File

@@ -4,7 +4,7 @@ import {
PointerType,
} from "./types";
import { getElementAbsoluteCoords } from "./bounds";
import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math";
import { AppState, Zoom } from "../types";
import { isTextElement } from ".";
@@ -81,7 +81,7 @@ const generateTransformHandle = (
};
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
[x1, y1, x2, y2]: Bounds,
angle: number,
zoom: Zoom,
pointerType: PointerType,
@@ -97,6 +97,8 @@ export const getTransformHandlesFromCoords = (
const width = x2 - x1;
const height = y2 - y1;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const dashedLineMargin = margin / zoom.value;
const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
@@ -254,7 +256,7 @@ export const getTransformHandles = (
? DEFAULT_SPACING + 8
: DEFAULT_SPACING;
return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element, true),
getElementAbsoluteCoords(element),
element.angle,
zoom,
pointerType,

View File

@@ -1,4 +1,3 @@
import { ROUNDNESS } from "../constants";
import { AppState } from "../types";
import {
ExcalidrawElement,
@@ -11,7 +10,6 @@ import {
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer,
RoundnessType,
} from "./types";
export const isGenericElement = (
@@ -62,12 +60,6 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type);
};
export const isArrowElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => {
return element != null && element.type === "arrow";
};
export const isLinearElementType = (
elementType: AppState["activeTool"]["type"],
): boolean => {
@@ -118,8 +110,7 @@ export const isTextBindableContainer = (
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
isArrowElement(element))
element.type === "image")
);
};
@@ -148,59 +139,6 @@ export const isBoundToContainer = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElementWithContainer => {
return (
element !== null &&
"containerId" in element &&
element.containerId !== null &&
isTextElement(element)
element !== null && isTextElement(element) && element.containerId !== null
);
};
export const isUsingAdaptiveRadius = (type: string) => type === "rectangle";
export const isUsingProportionalRadius = (type: string) =>
type === "line" || type === "arrow" || type === "diamond";
export const canApplyRoundnessTypeToElement = (
roundnessType: RoundnessType,
element: ExcalidrawElement,
) => {
if (
(roundnessType === ROUNDNESS.ADAPTIVE_RADIUS ||
// if legacy roundness, it can be applied to elements that currently
// use adaptive radius
roundnessType === ROUNDNESS.LEGACY) &&
isUsingAdaptiveRadius(element.type)
) {
return true;
}
if (
roundnessType === ROUNDNESS.PROPORTIONAL_RADIUS &&
isUsingProportionalRadius(element.type)
) {
return true;
}
return false;
};
export const getDefaultRoundnessTypeForElement = (
element: ExcalidrawElement,
) => {
if (
element.type === "arrow" ||
element.type === "line" ||
element.type === "diamond"
) {
return {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
};
}
if (element.type === "rectangle") {
return {
type: ROUNDNESS.ADAPTIVE_RADIUS,
};
}
return null;
};

View File

@@ -1,11 +1,5 @@
import { Point } from "../types";
import {
FONT_FAMILY,
ROUNDNESS,
TEXT_ALIGN,
THEME,
VERTICAL_ALIGN,
} from "../constants";
import { FONT_FAMILY, TEXT_ALIGN, THEME, VERTICAL_ALIGN } from "../constants";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid";
@@ -15,8 +9,7 @@ export type Theme = typeof THEME[keyof typeof THEME];
export type FontString = string & { _brand: "fontString" };
export type GroupId = string;
export type PointerType = "mouse" | "pen" | "touch";
export type StrokeRoundness = "round" | "sharp";
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
export type StrokeSharpness = "round" | "sharp";
export type StrokeStyle = "solid" | "dashed" | "dotted";
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
@@ -32,7 +25,7 @@ type _ExcalidrawElementBase = Readonly<{
fillStyle: FillStyle;
strokeWidth: number;
strokeStyle: StrokeStyle;
roundness: null | { type: RoundnessType; value?: number };
strokeSharpness: StrokeSharpness;
roughness: number;
opacity: number;
width: number;
@@ -148,8 +141,7 @@ export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawImageElement
| ExcalidrawArrowElement;
| ExcalidrawImageElement;
export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"];
@@ -174,11 +166,6 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null;
}>;
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{
type: "arrow";
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";

View File

@@ -242,12 +242,6 @@ class Collab extends PureComponent<Props, CollabState> {
);
}
} catch (error: any) {
this.setState({
// firestore doesn't return a specific error code when size exceeded
errorMessage: /is longer than.*?bytes/.test(error.message)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed"),
});
console.error(error);
}
};
@@ -263,6 +257,13 @@ class Collab extends PureComponent<Props, CollabState> {
),
);
if (this.portal.socket && this.fallbackInitializationHandler) {
this.portal.socket.off(
"connect_error",
this.fallbackInitializationHandler,
);
}
if (!keepRemoteState) {
LocalData.fileStorage.reset();
this.destroySocketClient();
@@ -358,6 +359,8 @@ class Collab extends PureComponent<Props, CollabState> {
}
};
private fallbackInitializationHandler: null | (() => any) = null;
startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => {
@@ -396,6 +399,7 @@ class Collab extends PureComponent<Props, CollabState> {
scenePromise.resolve(scene);
});
};
this.fallbackInitializationHandler = fallbackInitializationHandler;
try {
const socketServerData = await getCollabServer();
@@ -410,27 +414,7 @@ class Collab extends PureComponent<Props, CollabState> {
roomKey,
);
this.portal.socket.on("disconnect", (reason) => {
// reason `io server disconnect` probably means CORS issue
console.warn(
`${
this.portal.socketInitialized ? "initialized" : "uninitialized"
} socket disconnected from server: ${reason}`,
);
this.setState({
errorMessage: this.portal.socketInitialized
? t("errors.socketDisconnected")
: t("errors.socketConnectionError"),
});
fallbackInitializationHandler();
});
this.portal.socket.on("connect_error", () => {
fallbackInitializationHandler();
this.setState({
errorMessage: t("errors.socketConnectionError"),
});
});
this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) {
console.error(error);
this.setState({ errorMessage: error.message });
@@ -457,7 +441,6 @@ class Collab extends PureComponent<Props, CollabState> {
this.saveCollabRoomToFirebase(getSyncableElements(elements));
}
clearTimeout(this.socketInitializationTimer!);
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_INIT message
this.socketInitializationTimer = window.setTimeout(
@@ -568,12 +551,12 @@ class Collab extends PureComponent<Props, CollabState> {
}
| { fetchScene: false; roomLinkData?: null }) => {
clearTimeout(this.socketInitializationTimer!);
if (!this.portal.socket || this.portal.socketInitialized) {
return null;
if (this.portal.socket && this.fallbackInitializationHandler) {
this.portal.socket.off(
"connect_error",
this.fallbackInitializationHandler,
);
}
this.portal.socketInitialized = true;
if (fetchScene && roomLinkData && this.portal.socket) {
this.excalidrawAPI.resetScene();
@@ -596,7 +579,11 @@ class Collab extends PureComponent<Props, CollabState> {
} catch (error: any) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
} finally {
this.portal.socketInitialized = true;
}
} else {
this.portal.socketInitialized = true;
}
return null;
};

Some files were not shown because too many files have changed in this diff Show More