Compare commits

..

2 Commits

Author SHA1 Message Date
zsviczian
11ee139d5c lint 2024-06-13 18:55:17 +02:00
zsviczian
16c0417f5b Fix - binding to nonexistent element 2024-06-13 18:50:51 +02:00
202 changed files with 6149 additions and 13386 deletions

View File

@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_ENABLE_TRACKING=true
VITE_APP_DISABLE_TRACKING=true
FAST_REFRESH=false

View File

@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_ENABLE_TRACKING=false
VITE_APP_DISABLE_TRACKING=

View File

@@ -9,9 +9,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Setup Node.js 18.x
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Install and test

View File

@@ -90,7 +90,7 @@ function App() {
<img src={canvasUrl} alt="" />
</div>
<div style={{ height: "400px" }}>
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
/>
</div>
</>

View File

@@ -23,8 +23,8 @@
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",
"prism-react-renderer": "^1.3.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"sass": "1.57.1"
},
"devDependencies": {

View File

@@ -59,7 +59,7 @@ pre a {
padding: 5px;
background: #70b1ec;
color: white;
font-weight: 700;
font-weight: bold;
border: none;
}

View File

@@ -872,7 +872,7 @@ export default function App({
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}
@@ -893,7 +893,7 @@ export default function App({
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}

View File

@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
];
export default {
elements,
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
scrollToContent: true,
libraryItems: [
[

View File

@@ -34,6 +34,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# copied assets
public/*.woff2

View File

@@ -3,8 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",
@@ -13,13 +12,13 @@
"dependencies": {
"@excalidraw/excalidraw": "*",
"next": "14.1",
"react": "18.2.0",
"react-dom": "18.2.0"
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"path2d-polyfill": "2.0.1",
"typescript": "^5"
}

View File

@@ -1,5 +1,4 @@
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -16,9 +15,7 @@ export default function Page() {
<>
<a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1>
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
</Script>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly />
</>

View File

@@ -7,7 +7,7 @@ a {
color: #1c7ed6;
font-size: 20px;
text-decoration: none;
font-weight: 500;
font-weight: 550;
}
.page-title {

View File

@@ -1,2 +0,0 @@
# copied assets
public/*.woff2

View File

@@ -11,7 +11,6 @@
<title>React App</title>
<script>
window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
</head>

View File

@@ -12,10 +12,8 @@
"typescript": "^5"
},
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
"build:preview": "yarn build && vite preview --port 5002"
}
}

View File

@@ -1,4 +1,5 @@
import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState";
@@ -21,6 +22,7 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
@@ -91,7 +93,7 @@ import {
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { Provider, useAtom, useAtomValue } from "jotai";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
@@ -119,8 +121,6 @@ import {
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
polyfill();
@@ -172,6 +172,11 @@ if (window.self !== window.top) {
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
@@ -317,15 +322,19 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false };
};
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state
// ---------------------------------------------------------------------------
@@ -481,7 +490,11 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage());
let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
@@ -582,6 +595,10 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,

View File

@@ -1,25 +0,0 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "../../packages/excalidraw";
export const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
export const getPreferredLanguage = () => {
const detectedLanguages = languageDetector.detect();
const detectedLanguage = Array.isArray(detectedLanguages)
? detectedLanguages[0]
: detectedLanguages;
const initialLanguage =
(detectedLanguage
? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chinese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code;
return initialLanguage;
};

View File

@@ -1,15 +0,0 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
export const useAppLangCode = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
return [langCode, setLangCode] as const;
};

View File

@@ -6,7 +6,7 @@ import {
import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList";
import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;

View File

@@ -1,7 +1,8 @@
import { useSetAtom } from "jotai";
import React from "react";
import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { appLangCodeAtom } from "./language-state";
import { appLangCodeAtom } from "../App";
import { useI18n } from "../../packages/excalidraw/i18n";
import { languages } from "../../packages/excalidraw/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n();

View File

@@ -95,11 +95,6 @@
color: #fff;
}
</style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!------------------------------------------------------------------------->
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<script>
@@ -120,55 +115,8 @@
window.location.href = "https://app.excalidraw.com";
}
</script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -176,6 +124,22 @@
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="/Virgil.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/Cascadia.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script>
@@ -194,6 +158,7 @@
</script>
<% } %>
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>

View File

@@ -26,28 +26,17 @@
"node": ">=18.0.0"
},
"dependencies": {
"firebase": "8.3.3",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"start": "yarn && vite",
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
}
}

View File

@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center"
>
<div
class="welcome-screen-center__logo excalifont welcome-screen-decor"
class="welcome-screen-center__logo virgil welcome-screen-decor"
>
<div
class="ExcalidrawLogo is-small"
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div>
</div>
<div
class="welcome-screen-center__heading welcome-screen-decor excalifont"
class="welcome-screen-center__heading welcome-screen-decor virgil"
>
All your data is saved locally in your browser.
</div>

View File

@@ -5,7 +5,6 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables
const envVars = loadEnv("", `../`);
@@ -23,14 +22,6 @@ export default defineConfig({
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return '[name]-[hash][extname]';
}
return 'assets/[name]-[hash][extname]';
},
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
@@ -44,13 +35,12 @@ export default defineConfig({
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
}
},
},
},
sourcemap: true,
},
plugins: [
woff2BrowserPlugin(),
react(),
checker({
typescript: true,

View File

@@ -9,8 +9,19 @@
"examples/excalidraw",
"examples/excalidraw/*"
],
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
"@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
@@ -40,7 +51,7 @@
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "1.6.0",
"vitest": "1.5.3",
"vitest-canvas-mock": "0.3.2"
},
"engines": {
@@ -77,8 +88,5 @@
"prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js"
},
"resolutions": {
"@types/react": "18.2.0"
}
}

View File

@@ -15,12 +15,8 @@ Please add the latest change on the top under the correct section.
### Features
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)

View File

@@ -5,25 +5,19 @@ import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import type { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const framesToBeDeleted = new Set(
getSelectedElements(
@@ -35,26 +29,6 @@ const deleteSelectedElements = (
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, app.scene, bound.points);
}
});
}
return newElementWith(el, { isDeleted: true });
}
@@ -156,11 +130,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
app.scene,
);
LinearElementEditor.deletePoints(element, selectedPointsIndices);
return {
elements,
@@ -179,7 +149,7 @@ export const actionDeleteSelected = register({
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app);
deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),

View File

@@ -40,11 +40,12 @@ export const actionDuplicateSelection = register({
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene,
elementsMap,
);
if (!ret) {

View File

@@ -38,7 +38,6 @@ export const actionFinalize = register({
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
return {
@@ -132,13 +131,7 @@ export const actionFinalize = register({
-1,
arrayToMap(elements),
);
maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
elements,
);
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
}
}

View File

@@ -120,14 +120,11 @@ const flipElements = (
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
app.scene,
);
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
app,
isBindingEnabled(appState),
[],
);

View File

@@ -50,13 +50,12 @@ export const createUndoAction: ActionCreator = (history, store) => ({
icon: UndoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, value, app) =>
perform: (elements, appState) =>
writeData(appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
app.scene,
),
),
keyTest: (event) =>
@@ -92,13 +91,12 @@ export const createRedoAction: ActionCreator = (history, store) => ({
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, _, app) =>
perform: (elements, appState) =>
writeData(appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
app.scene,
),
),
keyTest: (event) =>

View File

@@ -1,6 +1,6 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
import { isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store";
import { register } from "./register";
@@ -29,8 +29,7 @@ export const actionToggleLinearEditor = register({
if (
!appState.editingLinearElement &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
isLinearElement(selectedElements[0])
) {
return true;
}

View File

@@ -155,15 +155,13 @@ describe("element locking", () => {
});
const text = API.createElement({
type: "text",
fontFamily: FONT_FAMILY["Comic Shanns"],
fontFamily: FONT_FAMILY.Cascadia,
});
h.elements = [rect, text];
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
});
});
});

View File

@@ -1,6 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
import type { StoreActionType } from "../store";
import type { AppClassProperties, AppState, Primitive } from "../types";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -11,7 +9,6 @@ import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
@@ -41,6 +38,9 @@ import {
FontSizeExtraLargeIcon,
EdgeSharpIcon,
EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
@@ -50,12 +50,8 @@ import {
ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon,
fontSizeIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons";
import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
@@ -69,17 +65,17 @@ import {
redrawTextBoundingBox,
} from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement";
import {
isArrowElement,
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
isBoundToContainer,
isElbowArrow,
isLinearElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import type {
Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
@@ -98,23 +94,9 @@ import {
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "../store";
import { Fonts, getLineHeight } from "../fonts";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -747,391 +729,104 @@ export const actionIncreaseFontSize = register({
},
});
type ChangeFontFamilyData = Partial<
Pick<
AppState,
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
>
> & {
/** cache of selected & editing elements populated on opened popup */
cachedElements?: Map<string, ExcalidrawElement>;
/** flag to reset all elements to their cached versions */
resetAll?: true;
/** flag to reset all containers to their cached versions */
resetContainers?: true;
};
export const actionChangeFontFamily = register({
name: "changeFontFamily",
label: "labels.fontFamily",
trackEvent: false,
perform: (elements, appState, value, app) => {
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
value as ChangeFontFamilyData;
if (resetAll) {
const nextElements = changeProperty(
return {
elements: changeProperty(
elements,
appState,
(element) => {
const cachedElement = cachedElements?.get(element.id);
if (cachedElement) {
const newElement = newElementWith(element, {
...cachedElement,
});
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: value,
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
return newElement;
}
return element;
return oldElement;
},
true,
);
return {
elements: nextElements,
appState: {
...appState,
...nextAppState,
},
storeAction: StoreAction.UPDATE,
};
}
const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nexStoreAction: StoreActionType = StoreAction.NONE;
let nextFontFamily: FontFamilyValues | undefined;
let skipOnHoverRender = false;
if (currentItemFontFamily) {
nextFontFamily = currentItemFontFamily;
nexStoreAction = StoreAction.CAPTURE;
} else if (currentHoveredFontFamily) {
nextFontFamily = currentHoveredFontFamily;
nexStoreAction = StoreAction.NONE;
const selectedTextElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).filter((element) => isTextElement(element));
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
if (selectedTextElements.length > 200) {
skipOnHoverRender = true;
} else {
let i = 0;
let textLengthAccumulator = 0;
while (
i < selectedTextElements.length &&
textLengthAccumulator < 5000
) {
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
textLengthAccumulator += textElement?.originalText.length || 0;
i++;
}
if (textLengthAccumulator > 5000) {
skipOnHoverRender = true;
}
}
}
const result = {
),
appState: {
...appState,
...nextAppState,
currentItemFontFamily: value,
},
storeAction: nexStoreAction,
storeAction: StoreAction.CAPTURE,
};
if (nextFontFamily && !skipOnHoverRender) {
const elementContainerMapping = new Map<
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueGlyphs = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
const fontFamily = Object.entries(FONT_FAMILY).find(
([_, value]) => value === nextFontFamily,
)?.[0];
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
if (
currentHoveredFontFamily &&
fontFamily &&
fontsCache.some((sig) => sig.startsWith(fontFamily))
) {
skipFontFaceCheck = true;
}
// following causes re-render so make sure we changed the family
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
Object.assign(result, {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (
isTextElement(oldElement) &&
(oldElement.fontFamily !== nextFontFamily ||
currentItemFontFamily) // force update on selection
) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: nextFontFamily,
lineHeight: getLineHeight(nextFontFamily!),
},
);
const cachedContainer =
cachedElements?.get(oldElement.containerId || "") || {};
const container = app.scene.getContainerElement(oldElement);
if (resetContainers && container && cachedContainer) {
// reset the container back to it's cached version
mutateElement(container, { ...cachedContainer }, false);
}
if (!skipFontFaceCheck) {
uniqueGlyphs = new Set([
...uniqueGlyphs,
...Array.from(newElement.originalText),
]);
}
elementContainerMapping.set(newElement, container);
return newElement;
}
return oldElement;
},
true,
),
});
// size is irrelevant, but necessary
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const glyphs = Array.from(uniqueGlyphs.values()).join();
if (
skipFontFaceCheck ||
window.document.fonts.check(fontString, glyphs)
) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
redrawTextBoundingBox(
element,
container,
app.scene.getNonDeletedElementsMap(),
false,
);
}
} else {
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id);
const latestContainer = container
? app.scene.getElement(container.id)
: null;
if (latestElement) {
// trigger async redraw
redrawTextBoundingBox(
latestElement as ExcalidrawTextElement,
latestContainer,
app.scene.getNonDeletedElementsMap(),
false,
);
}
}
// trigger update once we've mutated all the elements, which also updates our cache
app.fonts.onLoaded(fontFaces);
});
}
}
return result;
},
PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
const isUnmounted = useRef(true);
const selectedFontFamily = useMemo(() => {
const getFontFamily = (
elementsArray: readonly ExcalidrawElement[],
elementsMap: Map<string, ExcalidrawElement>,
) =>
getFormValue(
elementsArray,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(element, elementsMap) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
);
// popup opened, use cached elements
if (
batchedData.openPopup === "fontFamily" &&
appState.openPopup === "fontFamily"
) {
return getFontFamily(
Array.from(cachedElementsRef.current?.values() ?? []),
cachedElementsRef.current,
);
}
// popup closed, use all elements
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
}
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
return prevSelectedFontFamilyRef.current;
}, [batchedData.openPopup, appState, elements, app.scene]);
useEffect(() => {
prevSelectedFontFamilyRef.current = selectedFontFamily;
}, [selectedFontFamily]);
useEffect(() => {
if (Object.keys(batchedData).length) {
updateData(batchedData);
// reset the data after we've used the data
setBatchedData({});
}
// call update only on internal state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchedData]);
useEffect(() => {
isUnmounted.current = false;
return () => {
isUnmounted.current = true;
};
}, []);
PanelComponent: ({ elements, appState, updateData, app }) => {
const options: {
value: FontFamilyValues;
text: string;
icon: JSX.Element;
testId: string;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: FreedrawIcon,
testId: "font-family-virgil",
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: FontFamilyNormalIcon,
testId: "font-family-normal",
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: FontFamilyCodeIcon,
testId: "font-family-code",
},
];
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<FontPicker
isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily}
onSelect={(fontFamily) => {
setBatchedData({
openPopup: null,
currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
}}
onHover={(fontFamily) => {
setBatchedData({
currentHoveredFontFamily: fontFamily,
cachedElements: new Map(cachedElementsRef.current),
resetContainers: true,
});
}}
onLeave={() => {
setBatchedData({
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
});
}}
onPopupChange={(open) => {
if (open) {
// open, populate the cache from scratch
cachedElementsRef.current.clear();
const { editingElement } = appState;
if (editingElement?.type === "text") {
// retrieve the latest version from the scene, as `editingElement` isn't mutated
const latestEditingElement = app.scene.getElement(
editingElement.id,
);
// inside the wysiwyg editor
cachedElementsRef.current.set(
editingElement.id,
newElementWith(
latestEditingElement || editingElement,
{},
true,
),
);
} else {
const selectedElements = getSelectedElements(
elements,
appState,
{
includeBoundTextElement: true,
},
);
for (const element of selectedElements) {
cachedElementsRef.current.set(
element.id,
newElementWith(element, {}, true),
);
}
<ButtonIconSelect<FontFamilyValues | false>
group="font-family"
options={options}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
}
setBatchedData({
openPopup: "fontFamily",
});
} else {
// close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
} as ChangeFontFamilyData;
if (isUnmounted.current) {
// in case the component was unmounted by the parent, trigger the update directly
updateData({ ...batchedData, ...data });
} else {
setBatchedData(data);
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
cachedElementsRef.current.clear();
}
}}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
@@ -1324,12 +1019,8 @@ export const actionChangeRoundness = register({
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el)) {
return el;
}
return newElementWith(el, {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
roundness:
value === "round"
? {
@@ -1338,8 +1029,8 @@ export const actionChangeRoundness = register({
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
});
}),
}),
),
appState: {
...appState,
currentItemRoundness: value,
@@ -1379,8 +1070,7 @@ export const actionChangeRoundness = register({
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(element) => element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
)}
@@ -1543,219 +1233,3 @@ export const actionChangeArrowhead = register({
);
},
});
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
app.scene,
[finalStartPoint, finalEndPoint].map(
(point) =>
[point[0] - newElement.x, point[1] - newElement.y] as Point,
),
[0, 0],
{
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
},
);
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<legend>{t("labels.arrowtypes")}</legend>
<ButtonIconSelect
group="arrowtypes"
options={[
{
value: ARROW_TYPE.sharp,
text: t("labels.arrowtype_sharp"),
icon: sharpArrowIcon,
testId: "sharp-arrow",
},
{
value: ARROW_TYPE.round,
text: t("labels.arrowtype_round"),
icon: roundArrowIcon,
testId: "round-arrow",
},
{
value: ARROW_TYPE.elbow,
text: t("labels.arrowtype_elbowed"),
icon: elbowArrowIcon,
testId: "elbow-arrow",
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? ARROW_TYPE.elbow
: element.roundness
? ARROW_TYPE.round
: ARROW_TYPE.sharp;
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});

View File

@@ -12,7 +12,10 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
import { getBoundTextElement } from "../element/textElement";
import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
@@ -24,7 +27,6 @@ import { getSelectedElements } from "../scene";
import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons";
import { StoreAction } from "../store";
import { getLineHeight } from "../fonts";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@@ -120,7 +122,7 @@ export const actionPasteStyles = register({
DEFAULT_TEXT_ALIGN,
lineHeight:
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getLineHeight(fontFamily),
getDefaultLineHeight(fontFamily),
});
let container = null;
if (newElement.containerId) {

View File

@@ -70,7 +70,6 @@ export type ActionName =
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"

View File

@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
export const trackEvent = (
category: string,
@@ -9,20 +9,17 @@ export const trackEvent = (
value?: number,
) => {
try {
// prettier-ignore
if (
typeof window === "undefined" ||
import.meta.env.VITE_WORKER_ID ||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
) {
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
return;
}
if (import.meta.env.DEV) {
// comment out to debug in dev
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}

View File

@@ -1,6 +1,5 @@
import { COLOR_PALETTE } from "./colors";
import {
ARROW_TYPE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
@@ -34,11 +33,9 @@ export const getDefaultAppState = (): Omit<
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
activeEmbeddable: null,
draggingElement: null,
@@ -145,11 +142,6 @@ const APP_STATE_STORAGE_CONF = (<
export: false,
server: false,
},
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
@@ -157,7 +149,6 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },

View File

@@ -1,105 +0,0 @@
export default class BinaryHeap<T> {
private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {}
sinkDown(idx: number) {
const node = this.content[idx];
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
}
bubbleUp(idx: number) {
const length = this.content.length;
const node = this.content[idx];
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
}
}
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break;
}
}
}
push(node: T) {
this.content.push(node);
this.sinkDown(this.content.length - 1);
}
pop(): T | null {
if (this.content.length === 0) {
return null;
}
const result = this.content[0];
const end = this.content.pop()!;
if (this.content.length > 0) {
this.content[0] = end;
this.bubbleUp(0);
}
return result;
}
remove(node: T) {
if (this.content.length === 0) {
return;
}
const i = this.content.indexOf(node);
const end = this.content.pop()!;
if (i < this.content.length) {
this.content[i] = end;
if (this.scoreFunction(end) < this.scoreFunction(node)) {
this.sinkDown(i);
} else {
this.bubbleUp(i);
}
}
}
size(): number {
return this.content.length;
}
rescoreElement(node: T) {
this.sinkDown(this.content.indexOf(node));
}
}

View File

@@ -29,7 +29,6 @@ import type {
} from "./element/types";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { getNonDeletedGroupIds } from "./groups";
import type Scene from "./scene/Scene";
import { getObservedAppState } from "./store";
import type {
AppState,
@@ -1054,7 +1053,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
public applyTo(
elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
scene: Scene,
): [SceneElementsMap, boolean] {
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
let changedElements: Map<string, OrderedExcalidrawElement>;
@@ -1102,7 +1100,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
ElementsChange.redrawBoundArrows(nextElements, changedElements, scene);
ElementsChange.redrawBoundArrows(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
@@ -1459,13 +1457,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
private static redrawBoundArrows(
elements: SceneElementsMap,
changed: Map<string, OrderedExcalidrawElement>,
scene: Scene,
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, scene, {
changedElements: changed,
});
updateBoundElements(element, elements);
}
}
}

View File

@@ -257,6 +257,8 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth,
points: [
[0, 0],
@@ -271,6 +273,8 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight,
points: [
[0, 0],
@@ -285,6 +289,8 @@ const chartLines = (
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
@@ -412,6 +418,8 @@ const chartTypeLine = (
type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY,
width: maxX - minX,
strokeWidth: 2,
@@ -445,6 +453,8 @@ const chartTypeLine = (
type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy,
startArrowhead: null,
endArrowhead: null,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,

View File

@@ -21,11 +21,10 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import {
hasBoundTextElement,
isElbowArrow,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
@@ -122,8 +121,7 @@ export const SelectedShapeActions = ({
const showLineEditorAction =
!appState.editingLinearElement &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
isLinearElement(targetElements[0]);
return (
<div className="panelColumn">
@@ -157,16 +155,13 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</>
)}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</>
)}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}

View File

@@ -48,7 +48,8 @@ import {
} from "../appState";
import type { PastedMixedContent } from "../clipboard";
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants";
import type { EXPORT_IMAGE_TYPES } from "../constants";
import { DEFAULT_FONT_SIZE } from "../constants";
import {
APP_NAME,
CURSOR_TYPE,
@@ -142,7 +143,6 @@ import {
newEmbeddableElement,
newMagicFrameElement,
newIframeElement,
newArrowElement,
} from "../element/newElement";
import {
hasBoundTextElement,
@@ -157,11 +157,11 @@ import {
isLinearElement,
isLinearElementType,
isUsingAdaptiveRadius,
isFrameElement,
isIframeElement,
isIframeLikeElement,
isMagicFrameElement,
isTextBindableContainer,
isElbowArrow,
} from "../element/typeChecks";
import type {
ExcalidrawBindableElement,
@@ -183,7 +183,6 @@ import type {
ExcalidrawIframeElement,
ExcalidrawEmbeddableElement,
Ordered,
ExcalidrawArrowElement,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
@@ -226,8 +225,16 @@ import type {
ScrollBars,
} from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes";
import { getSelectionBoxShape } from "../../utils/geometry/shape";
import { findShapeByKey } from "../shapes";
import type { GeometricShape } from "../../utils/geometry/shape";
import {
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
getSelectionBoxShape,
} from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision";
import type {
AppClassProperties,
@@ -322,6 +329,7 @@ import {
getBoundTextElement,
getContainerCenter,
getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
@@ -337,7 +345,7 @@ import {
import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts, getLineHeight } from "../fonts";
import { Fonts } from "../scene/Fonts";
import {
getFrameChildren,
isCursorInFrame,
@@ -416,6 +424,7 @@ import {
hitElementBoundText,
hitElementBoundingBoxOnly,
hitElementItself,
shouldTestInside,
} from "../element/collision";
import { textWysiwyg } from "../element/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars";
@@ -428,7 +437,6 @@ import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid";
import { mutateElbowArrow } from "../element/routing";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -533,8 +541,8 @@ class App extends React.Component<AppProps, AppState> {
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
public scene: Scene;
public fonts: Fonts;
public renderer: Renderer;
private fonts: Fonts;
private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"];
@@ -1291,7 +1299,15 @@ class App extends React.Component<AppProps, AppState> {
const isDarkTheme = this.state.theme === THEME.DARK;
let frameIndex = 0;
let magicFrameIndex = 0;
return this.scene.getNonDeletedFramesLikes().map((f) => {
if (isFrameElement(f)) {
frameIndex++;
} else {
magicFrameIndex++;
}
if (
!isElementInViewport(
f,
@@ -1325,7 +1341,10 @@ class App extends React.Component<AppProps, AppState> {
let frameNameJSX;
const frameName = getFrameLikeTitle(f);
const frameName = getFrameLikeTitle(
f,
isFrameElement(f) ? frameIndex : magicFrameIndex,
);
if (f.id === this.state.editingFrame) {
const frameNameInEdit = frameName;
@@ -2116,14 +2135,6 @@ class App extends React.Component<AppProps, AppState> {
});
};
public dismissLinearEditor = () => {
setTimeout(() => {
this.setState({
editingLinearElement: null,
});
});
};
public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) {
return;
@@ -2280,11 +2291,7 @@ class App extends React.Component<AppProps, AppState> {
}
let initialData = null;
try {
if (typeof this.props.initialData === "function") {
initialData = (await this.props.initialData()) || null;
} else {
initialData = (await this.props.initialData) || null;
}
initialData = (await this.props.initialData) || null;
if (initialData?.libraryItems) {
this.library
.updateLibrary({
@@ -2333,6 +2340,11 @@ class App extends React.Component<AppProps, AppState> {
}),
};
}
// FontFaceSet loadingdone event we listen on may not always fire
// (looking at you Safari), so on init we manually load fonts for current
// text elements on canvas, and rerender them once done. This also
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadFontsForElements(scene.elements);
this.resetStore();
this.resetHistory();
@@ -2340,12 +2352,6 @@ class App extends React.Component<AppProps, AppState> {
...scene,
storeAction: StoreAction.UPDATE,
});
// FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all
// fonts and rerender scene text elements once done. This also
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadSceneFonts();
};
private isMobileBreakpoint = (width: number, height: number) => {
@@ -2438,10 +2444,6 @@ class App extends React.Component<AppProps, AppState> {
configurable: true,
value: this.store,
},
fonts: {
configurable: true,
value: this.fonts,
},
});
}
@@ -2492,9 +2494,7 @@ class App extends React.Component<AppProps, AppState> {
}
public componentWillUnmount() {
(window as any).launchQueue?.setConsumer(() => {});
this.renderer.destroy();
this.scene.destroy();
this.scene = new Scene();
this.fonts = new Fonts({ scene: this.scene });
this.renderer = new Renderer(this.scene);
@@ -2503,6 +2503,7 @@ class App extends React.Component<AppProps, AppState> {
this.resizeObserver?.disconnect();
this.unmounted = true;
this.removeEventListeners();
this.scene.destroy();
this.library.destroy();
this.laserTrails.stop();
this.eraserTrail.stop();
@@ -2579,7 +2580,7 @@ class App extends React.Component<AppProps, AppState> {
// rerender text elements on font load to fix #637 && #1553
addEventListener(document.fonts, "loadingdone", (event) => {
const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
this.fonts.onLoaded(loadedFontFaces);
this.fonts.onFontsLoaded(loadedFontFaces);
}),
// Safari-only desktop pinch zoom
addEventListener(
@@ -2814,8 +2815,7 @@ class App extends React.Component<AppProps, AppState> {
nonDeletedElementsMap,
),
),
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this,
);
}
@@ -3058,7 +3058,9 @@ class App extends React.Component<AppProps, AppState> {
try {
const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text);
await api.parseMermaidToExcalidraw(data.text, {
fontSize: DEFAULT_FONT_SIZE,
});
const elements = convertToExcalidrawElements(skeletonElements, {
regenerateIds: true,
@@ -3383,7 +3385,7 @@ class App extends React.Component<AppProps, AppState> {
fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily,
});
const lineHeight = getLineHeight(textElementProps.fontFamily);
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
const [x1, , x2] = getVisibleSceneBounds(this.state);
// long texts should not go beyond 800 pixels in width nor should it go below 200 px
const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
@@ -3401,13 +3403,13 @@ class App extends React.Component<AppProps, AppState> {
});
let metrics = measureText(originalText, fontString, lineHeight);
const isTextUnwrapped = metrics.width > maxTextWidth;
const isTextWrapped = metrics.width > maxTextWidth;
const text = isTextUnwrapped
const text = isTextWrapped
? wrapText(originalText, fontString, maxTextWidth)
: originalText;
metrics = isTextUnwrapped
metrics = isTextWrapped
? measureText(text, fontString, lineHeight)
: metrics;
@@ -3421,7 +3423,7 @@ class App extends React.Component<AppProps, AppState> {
text,
originalText,
lineHeight,
autoResize: !isTextUnwrapped,
autoResize: !isTextWrapped,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
acc.push(element);
@@ -3960,27 +3962,14 @@ class App extends React.Component<AppProps, AppState> {
}
if (isArrowKey(event.key)) {
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const elbowArrow = selectedElements.find(isElbowArrow) as
| ExcalidrawArrowElement
| undefined;
const step = elbowArrow
? elbowArrow.startBinding || elbowArrow.endBinding
? 0
: ELEMENT_TRANSLATE_AMOUNT
: (this.state.gridSize &&
(event.shiftKey
? ELEMENT_TRANSLATE_AMOUNT
: this.state.gridSize)) ||
const step =
(this.state.gridSize &&
(event.shiftKey
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
: ELEMENT_TRANSLATE_AMOUNT);
? ELEMENT_TRANSLATE_AMOUNT
: this.state.gridSize)) ||
(event.shiftKey
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
: ELEMENT_TRANSLATE_AMOUNT);
let offsetX = 0;
let offsetY = 0;
@@ -3995,28 +3984,27 @@ class App extends React.Component<AppProps, AppState> {
offsetY = step;
}
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
selectedElements.forEach((element) => {
mutateElement(element, {
x: element.x + offsetX,
y: element.y + offsetY,
});
updateBoundElements(
element,
this.scene.getNonDeletedElementsMap(),
this.scene,
{
simultaneouslyUpdated: selectedElements,
},
);
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements,
});
});
this.setState({
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements.filter(
(element) => element.id !== elbowArrow?.id || step !== 0,
),
this.scene.getNonDeletedElementsMap(),
selectedElements,
this,
),
});
@@ -4033,13 +4021,11 @@ class App extends React.Component<AppProps, AppState> {
selectedElements[0].id
) {
this.store.shouldCaptureIncrement();
if (!isElbowArrow(selectedElement)) {
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElement,
),
});
}
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElement,
),
});
}
}
} else if (
@@ -4087,16 +4073,6 @@ class App extends React.Component<AppProps, AppState> {
})`,
);
}
if (shape === "arrow" && this.state.activeTool.type === "arrow") {
this.setState((prevState) => ({
currentItemArrowType:
prevState.currentItemArrowType === ARROW_TYPE.sharp
? ARROW_TYPE.round
: prevState.currentItemArrowType === ARROW_TYPE.round
? ARROW_TYPE.elbow
: ARROW_TYPE.sharp,
}));
}
this.setActiveTool({ type: shape });
event.stopPropagation();
} else if (event.key === KEYS.Q) {
@@ -4137,36 +4113,6 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (
!event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.F
) {
const selectedElements = this.scene.getSelectedElements(this.state);
if (
this.state.activeTool.type === "selection" &&
!selectedElements.length
) {
return;
}
if (
this.state.activeTool.type === "text" ||
selectedElements.find(
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
this.scene.getNonDeletedElementsMap(),
),
)
) {
event.preventDefault();
this.setState({ openPopup: "fontFamily" });
}
}
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" });
@@ -4229,9 +4175,7 @@ class App extends React.Component<AppProps, AppState> {
if (isArrowKey(event.key)) {
bindOrUnbindLinearElements(
this.scene.getSelectedElements(this.state).filter(isLinearElement),
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
this,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
);
@@ -4463,7 +4407,7 @@ class App extends React.Component<AppProps, AppState> {
onChange: withBatchedUpdates((nextOriginalText) => {
updateElement(nextOriginalText, false);
if (isNonDeletedElement(element)) {
updateBoundElements(element, elementsMap, this.scene);
updateBoundElements(element, elementsMap);
}
}),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
@@ -4543,6 +4487,84 @@ class App extends React.Component<AppProps, AppState> {
return null;
}
/**
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
public getElementShape(element: ExcalidrawElement): GeometricShape {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
this.scene.getNonDeletedElementsMap(),
);
return shouldTestInside(element)
? getClosedCurveShape(
element,
roughShape,
[element.x, element.y],
element.angle,
[cx, cy],
)
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
cx,
cy,
]);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
this.scene.getNonDeletedElementsMap(),
);
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
}
}
}
private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
const boundTextElement = getBoundTextElement(
element,
this.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
if (element.type === "arrow") {
return this.getElementShape({
...boundTextElement,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
this.scene.getNonDeletedElementsMap(),
),
});
}
return this.getElementShape(boundTextElement);
}
return null;
}
private getElementAtPosition(
x: number,
y: number,
@@ -4576,10 +4598,7 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element: elementWithHighestZIndex,
shape: getElementShape(
elementWithHighestZIndex,
this.scene.getNonDeletedElementsMap(),
),
shape: this.getElementShape(elementWithHighestZIndex),
// when overlapping, we would like to be more precise
// this also avoids the need to update past tests
threshold: this.getElementHitThreshold() / 2,
@@ -4674,7 +4693,7 @@ class App extends React.Component<AppProps, AppState> {
const hitBoundTextOfElement = hitElementBoundText(
x,
y,
getBoundTextShape(element, this.scene.getNonDeletedElementsMap()),
this.getBoundTextShape(element),
);
if (hitBoundTextOfElement) {
return true;
@@ -4684,7 +4703,7 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element,
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
shape: this.getElementShape(element),
threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element)
@@ -4716,10 +4735,7 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
element: elements[index],
shape: getElementShape(
elements[index],
this.scene.getNonDeletedElementsMap(),
),
shape: this.getElementShape(elements[index]),
threshold: this.getElementHitThreshold(),
})
) {
@@ -4792,7 +4808,7 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight =
existingTextElement?.lineHeight || getLineHeight(fontFamily);
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize;
if (
@@ -4912,9 +4928,7 @@ class App extends React.Component<AppProps, AppState> {
if (
event[KEYS.CTRL_OR_CMD] &&
(!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !==
selectedElements[0].id) &&
!isElbowArrow(selectedElements[0])
this.state.editingLinearElement.elementId !== selectedElements[0].id)
) {
this.store.shouldCaptureIncrement();
this.setState({
@@ -4979,10 +4993,7 @@ class App extends React.Component<AppProps, AppState> {
x: sceneX,
y: sceneY,
element: container,
shape: getElementShape(
container,
this.scene.getNonDeletedElementsMap(),
),
shape: this.getElementShape(container),
threshold: this.getElementHitThreshold(),
})
) {
@@ -5257,7 +5268,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointerX,
scenePointerY,
this.state,
this.scene,
this.scene.getNonDeletedElementsMap(),
);
if (
@@ -5344,9 +5355,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(
scenePointerX,
scenePointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
? null
: this.state.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const [lastCommittedX, lastCommittedY] =
@@ -5370,35 +5379,16 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
if (isElbowArrow(multiElement)) {
mutateElbowArrow(
multiElement,
this.scene,
// update last uncommitted point
mutateElement(multiElement, {
points: [
...points.slice(0, -1),
[
...points.slice(0, -1),
[
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
undefined,
undefined,
{
isDragging: true,
},
);
} else {
// update last uncommitted point
mutateElement(multiElement, {
points: [
...points.slice(0, -1),
[
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
],
});
}
],
});
}
return;
@@ -5433,9 +5423,8 @@ class App extends React.Component<AppProps, AppState> {
}
if (
(!this.state.selectedLinearElement ||
this.state.selectedLinearElement.hoverPointIndex === -1) &&
!(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
!this.state.selectedLinearElement ||
this.state.selectedLinearElement.hoverPointIndex === -1
) {
const elementWithTransformHandleType =
getElementWithTransformHandleType(
@@ -5696,10 +5685,7 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX,
y: scenePointerY,
element,
shape: getElementShape(
element,
this.scene.getNonDeletedElementsMap(),
),
shape: this.getElementShape(element),
})
) {
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
@@ -5723,12 +5709,7 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if (
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (
@@ -6302,7 +6283,6 @@ class App extends React.Component<AppProps, AppState> {
const origin = viewportCoordsToSceneCoords(event, this.state);
const selectedElements = this.scene.getSelectedElements(this.state);
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
const isElbowArrowOnly = selectedElements.findIndex(isElbowArrow) === 0;
return {
origin,
@@ -6311,9 +6291,7 @@ class App extends React.Component<AppProps, AppState> {
getGridPoint(
origin.x,
origin.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly
? null
: this.state.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
),
),
scrollbars: isOverScrollBars(
@@ -6494,7 +6472,7 @@ class App extends React.Component<AppProps, AppState> {
this.store,
pointerDownState.origin,
linearElementEditor,
this.scene,
this,
);
if (ret.hitElement) {
pointerDownState.hit.element = ret.hitElement;
@@ -6826,8 +6804,7 @@ class App extends React.Component<AppProps, AppState> {
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this,
);
this.scene.insertElement(element);
this.setState({
@@ -6997,17 +6974,6 @@ class App extends React.Component<AppProps, AppState> {
return;
}
// Elbow arrows cannot be created by putting down points
// only the start and end points can be defined
if (isElbowArrow(multiElement) && multiElement.points.length > 1) {
mutateElement(multiElement, {
lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
});
this.actionManager.executeAction(actionFinalize);
return;
}
const { x: rx, y: ry, lastCommittedPoint } = multiElement;
// clicking inside commit zone → finalize arrow
@@ -7063,50 +7029,26 @@ class App extends React.Component<AppProps, AppState> {
? [currentItemStartArrowhead, currentItemEndArrowhead]
: [null, null];
const element =
elementType === "arrow"
? newArrowElement({
type: elementType,
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemArrowType === ARROW_TYPE.round
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: // note, roundness doesn't have any effect for elbow arrows,
// but it's best to set it to null as well
null,
startArrowhead,
endArrowhead,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
})
: newLinearElement({
type: elementType,
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemRoundness === "round"
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: null,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
const element = newLinearElement({
type: elementType,
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemRoundness === "round"
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: null,
startArrowhead,
endArrowhead,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
@@ -7124,9 +7066,7 @@ class App extends React.Component<AppProps, AppState> {
});
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
isElbowArrow(element),
this,
);
this.scene.insertElement(element);
@@ -7463,7 +7403,7 @@ class App extends React.Component<AppProps, AppState> {
);
},
linearElementEditor,
this.scene,
this.scene.getNonDeletedElementsMap(),
);
if (didDrag) {
pointerDownState.lastCoords.x = pointerCoords.x;
@@ -7587,24 +7527,18 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState,
selectedElements,
dragOffset,
this.state,
this.scene,
snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
if (
selectedElements.length !== 1 ||
!isElbowArrow(selectedElements[0])
) {
this.setState({
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
),
});
}
//}
this.setState({
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this,
),
});
// We duplicate the selected element if alt is pressed on pointer move
if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
@@ -7744,17 +7678,6 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(draggingElement, {
points: [...points, [dx, dy]],
});
} else if (points.length > 1 && isElbowArrow(draggingElement)) {
mutateElbowArrow(
draggingElement,
this.scene,
[...points.slice(0, -1), [dx, dy]],
[0, 0],
undefined,
{
isDragging: true,
},
);
} else if (points.length === 2) {
mutateElement(draggingElement, {
points: [...points.slice(0, -1), [dx, dy]],
@@ -7960,7 +7883,7 @@ class App extends React.Component<AppProps, AppState> {
childEvent,
this.state.editingLinearElement,
this.state,
this.scene,
this,
);
if (editingLinearElement !== this.state.editingLinearElement) {
this.setState({
@@ -7984,7 +7907,7 @@ class App extends React.Component<AppProps, AppState> {
childEvent,
this.state.selectedLinearElement,
this.state,
this.scene,
this,
);
const { startBindingElement, endBindingElement } =
@@ -7996,7 +7919,6 @@ class App extends React.Component<AppProps, AppState> {
startBindingElement,
endBindingElement,
elementsMap,
this.scene,
);
}
@@ -8135,8 +8057,7 @@ class App extends React.Component<AppProps, AppState> {
draggingElement,
this.state,
pointerCoords,
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this,
);
}
this.setState({ suggestedBindings: [], startBoundElement: null });
@@ -8626,10 +8547,7 @@ class App extends React.Component<AppProps, AppState> {
x: pointerDownState.origin.x,
y: pointerDownState.origin.y,
element: hitElement,
shape: getElementShape(
hitElement,
this.scene.getNonDeletedElementsMap(),
),
shape: this.getElementShape(hitElement),
threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(hitElement)
? this.frameNameBoundsCache.get(hitElement)
@@ -8697,9 +8615,7 @@ class App extends React.Component<AppProps, AppState> {
bindOrUnbindLinearElements(
linearElements,
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
this,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
);
@@ -9187,8 +9103,7 @@ class App extends React.Component<AppProps, AppState> {
}): void => {
const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this,
);
this.setState({
suggestedBindings:
@@ -9215,9 +9130,7 @@ class App extends React.Component<AppProps, AppState> {
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
isArrowElement(linearElement) && isElbowArrow(linearElement),
this,
);
if (
hoveredBindableElement != null &&
@@ -9745,12 +9658,11 @@ class App extends React.Component<AppProps, AppState> {
resizeY,
pointerDownState.resize.center.x,
pointerDownState.resize.center.y,
this.scene,
)
) {
const suggestedBindings = getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this,
);
const elementsToHighlight = new Set<ExcalidrawElement>();
@@ -10062,7 +9974,6 @@ class App extends React.Component<AppProps, AppState> {
declare global {
interface Window {
h: {
scene: Scene;
elements: readonly ExcalidrawElement[];
state: AppState;
setState: React.Component<any, AppState>["setState"];
@@ -10089,12 +10000,6 @@ export const createTestHook = () => {
);
},
},
scene: {
configurable: true,
get() {
return this.app?.scene;
},
},
});
}
};

View File

@@ -1,12 +0,0 @@
@import "../css/theme";
.excalidraw {
button.standalone {
@include outlineButtonIconStyles;
& > * {
// dissalow pointer events on children, so we always have event.target on the button itself
pointer-events: none;
}
}
}

View File

@@ -1,36 +0,0 @@
import { forwardRef } from "react";
import clsx from "clsx";
import "./ButtonIcon.scss";
interface ButtonIconProps {
icon: JSX.Element;
title: string;
className?: string;
testId?: string;
/** if not supplied, defaults to value identity check */
active?: boolean;
/** include standalone style (could interfere with parent styles) */
standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
(props, ref) => {
const { title, className, testId, active, standalone, icon, onClick } =
props;
return (
<button
type="button"
ref={ref}
key={title}
title={title}
data-testid={testId}
className={clsx(className, { standalone, active })}
onClick={onClick}
>
{icon}
</button>
);
},
);

View File

@@ -1,5 +1,4 @@
import clsx from "clsx";
import { ButtonIcon } from "./ButtonIcon";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>(
@@ -25,17 +24,21 @@ export const ButtonIconSelect = <T extends Object>(
}
),
) => (
<div className="buttonList">
<div className="buttonList buttonListIcon">
{props.options.map((option) =>
props.type === "button" ? (
<ButtonIcon
<button
type="button"
key={option.text}
icon={option.icon}
title={option.text}
testId={option.testId}
active={option.active ?? props.value === option.value}
onClick={(event) => props.onClick(option.value, event)}
/>
className={clsx({
active: option.active ?? props.value === option.value,
})}
data-testid={option.testId}
title={option.text}
>
{option.icon}
</button>
) : (
<label
key={option.text}

View File

@@ -1,10 +0,0 @@
export const ButtonSeparator = () => (
<div
style={{
width: 1,
height: "1rem",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
);

View File

@@ -20,7 +20,7 @@
align-items: center;
@include isMobile {
max-width: 11rem;
max-width: 175px;
}
}

View File

@@ -1,24 +1,22 @@
import { isTransparent } from "../../utils";
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import type { ExcalidrawElement } from "../../element/types";
import type { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useExcalidrawContainer } from "../App";
import { useDevice, useExcalidrawContainer } from "../App";
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
import { COLOR_PALETTE } from "../../colors";
import PickerHeading from "./PickerHeading";
import { t } from "../../i18n";
import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import "./ColorPicker.scss";
@@ -73,7 +71,6 @@ const ColorPickerPopupContent = ({
| "palette"
| "updateData"
>) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
@@ -81,6 +78,9 @@ const ColorPickerPopupContent = ({
jotaiScope,
);
const { container } = useExcalidrawContainer();
const device = useDevice();
const colorInputJSX = (
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
@@ -94,7 +94,6 @@ const ColorPickerPopupContent = ({
/>
</div>
);
const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
@@ -104,73 +103,120 @@ const ColorPickerPopupContent = ({
};
return (
<PropertiesPopover
container={container}
style={{ maxWidth: "208px" }}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
<Popover.Portal container={container}>
<Popover.Content
ref={popoverRef}
className="focus-visible-none"
data-prevent-outside-click
onFocusOutside={(event) => {
focusPickerContent();
event.preventDefault();
}
}}
onClose={() => {
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
return force === false || state
? null
: {
keepOpenOnAlt: false,
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: "var(--zIndex-layerUI)",
backgroundColor: "var(--popup-bg-color)",
maxWidth: "208px",
maxHeight: window.innerHeight,
padding: "12px",
borderRadius: "8px",
boxSizing: "border-box",
overflowY: "auto",
boxShadow:
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
}}
>
{palette ? (
<Picker
palette={palette}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
});
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
/>
</Popover.Content>
</Popover.Portal>
);
};
@@ -186,7 +232,7 @@ const ColorPickerTrigger = ({
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
className={clsx("color-picker__button active-color", {
"is-transparent": color === "transparent" || !color,
})}
aria-label={label}
@@ -222,7 +268,14 @@ export const ColorPicker = ({
type={type}
topPicks={topPicks}
/>
<ButtonSeparator />
<div
style={{
width: 1,
height: "100%",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {

View File

@@ -138,7 +138,7 @@ export const Picker = ({
event.stopPropagation();
}
}}
className="color-picker-content properties-content"
className="color-picker-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>

View File

@@ -1,15 +0,0 @@
@import "../../css/variables.module.scss";
.excalidraw {
.FontPicker__container {
display: grid;
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
align-items: center;
@include isMobile {
max-width: calc(
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
}
}

View File

@@ -1,110 +0,0 @@
import React, { useCallback, useMemo } from "react";
import * as Popover from "@radix-ui/react-popover";
import { FontPickerList } from "./FontPickerList";
import { FontPickerTrigger } from "./FontPickerTrigger";
import { ButtonIconSelect } from "../ButtonIconSelect";
import {
FontFamilyCodeIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../icons";
import { ButtonSeparator } from "../ButtonSeparator";
import type { FontFamilyValues } from "../../element/types";
import { FONT_FAMILY } from "../../constants";
import { t } from "../../i18n";
import "./FontPicker.scss";
export const DEFAULT_FONTS = [
{
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-handrawn",
},
{
value: FONT_FAMILY.Nunito,
icon: FontFamilyNormalIcon,
text: t("labels.normal"),
testId: "font-family-normal",
},
{
value: FONT_FAMILY["Comic Shanns"],
icon: FontFamilyCodeIcon,
text: t("labels.code"),
testId: "font-family-code",
},
];
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
export const isDefaultFont = (fontFamily: number | null) => {
if (!fontFamily) {
return false;
}
return defaultFontFamilies.has(fontFamily);
};
interface FontPickerProps {
isOpened: boolean;
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (fontFamily: FontFamilyValues) => void;
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
}
export const FontPicker = React.memo(
({
isOpened,
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onPopupChange,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
(value: number | false) => {
if (value) {
onSelect(value);
}
},
[onSelect],
);
return (
<div role="dialog" aria-modal="true" className="FontPicker__container">
<ButtonIconSelect<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={hoveredFontFamily}
onSelect={onSelectCallback}
onHover={onHover}
onLeave={onLeave}
onOpen={() => onPopupChange(true)}
onClose={() => onPopupChange(false)}
/>
)}
</Popover.Root>
</div>
);
},
(prev, next) =>
prev.isOpened === next.isOpened &&
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

View File

@@ -1,268 +0,0 @@
import React, {
useMemo,
useState,
useRef,
useEffect,
useCallback,
type KeyboardEventHandler,
} from "react";
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import { type FontFamilyValues } from "../../element/types";
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types";
export interface FontDescriptor {
value: number;
icon: JSX.Element;
text: string;
deprecated?: true;
badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>;
placeholder: string;
};
}
interface FontPickerListProps {
selectedFontFamily: FontFamilyValues | null;
hoveredFontFamily: FontFamilyValues | null;
onSelect: (value: number) => void;
onHover: (value: number) => void;
onLeave: () => void;
onOpen: () => void;
onClose: () => void;
}
export const FontPickerList = React.memo(
({
selectedFontFamily,
hoveredFontFamily,
onSelect,
onHover,
onLeave,
onOpen,
onClose,
}: FontPickerListProps) => {
const { container } = useExcalidrawContainer();
const { fonts } = useApp();
const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fonts }]) => {
const fontDescriptor = {
value: familyId,
icon: metadata.icon,
text: fonts[0].fontFace.family,
};
if (metadata.deprecated) {
Object.assign(fontDescriptor, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
}
return fontDescriptor as FontDescriptor;
})
.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
),
[],
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFontFamilies()),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
);
const sceneFonts = useMemo(
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
[allFonts, sceneFamilies],
);
const availableFonts = useMemo(
() =>
allFonts.filter(
(font) =>
!sceneFamilies.has(font.value) &&
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
),
[allFonts, sceneFamilies, showDeprecatedFonts],
);
const filteredFonts = useMemo(
() =>
arrayToList(
[...sceneFonts, ...availableFonts].filter((font) =>
font.text?.toLowerCase().includes(searchTerm),
),
),
[sceneFonts, availableFonts, searchTerm],
);
const hoveredFont = useMemo(() => {
let font;
if (hoveredFontFamily) {
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
} else if (selectedFontFamily) {
font = filteredFonts.find((font) => font.value === selectedFontFamily);
}
if (!font && searchTerm) {
if (filteredFonts[0]?.value) {
// hover first element on search
onHover(filteredFonts[0].value);
} else {
// re-render cache on no results
onLeave();
}
}
return font;
}, [
hoveredFontFamily,
selectedFontFamily,
searchTerm,
filteredFonts,
onHover,
onLeave,
]);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
const handled = fontPickerKeyHandler({
event,
inputRef,
hoveredFont,
filteredFonts,
onSelect,
onHover,
onClose,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
);
useEffect(() => {
onOpen();
return () => {
onClose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sceneFilteredFonts = useMemo(
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const availableFilteredFonts = useMemo(
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
[filteredFonts, sceneFamilies],
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
key={font.value}
icon={font.icon}
value={font.value}
order={index}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
onSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
{font.text}
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)}
</DropdownMenuItem>
);
const groups = [];
if (sceneFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map(renderFont)}
</DropdownMenuGroup>,
);
}
if (availableFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) =>
renderFont(font, index + sceneFilteredFonts.length),
)}
</DropdownMenuGroup>,
);
}
return (
<PropertiesPopover
className="properties-content"
container={container}
style={{ width: "15rem" }}
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<QuickSearch
ref={inputRef}
placeholder={t("quickSearch.placeholder")}
onChange={debounce(setSearchTerm, 20)}
/>
<ScrollableList
className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")}
>
{groups.length ? groups : null}
</ScrollableList>
</PropertiesPopover>
);
},
(prev, next) =>
prev.selectedFontFamily === next.selectedFontFamily &&
prev.hoveredFontFamily === next.hoveredFontFamily,
);

View File

@@ -1,38 +0,0 @@
import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
}
export const FontPickerTrigger = ({
selectedFontFamily,
}: FontPickerTriggerProps) => {
const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
return (
<Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op
onClick={() => {}}
/>
</div>
</Popover.Trigger>
);
};

View File

@@ -1,66 +0,0 @@
import type { Node } from "../../utils";
import { KEYS } from "../../keys";
import { type FontDescriptor } from "./FontPickerList";
interface FontPickerKeyNavHandlerProps {
event: React.KeyboardEvent<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
hoveredFont: Node<FontDescriptor> | undefined;
filteredFonts: Node<FontDescriptor>[];
onClose: () => void;
onSelect: (value: number) => void;
onHover: (value: number) => void;
}
export const fontPickerKeyHandler = ({
event,
inputRef,
hoveredFont,
filteredFonts,
onClose,
onSelect,
onHover,
}: FontPickerKeyNavHandlerProps) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.F
) {
// refocus input on the popup trigger shortcut
inputRef.current?.focus();
return true;
}
if (event.key === KEYS.ESCAPE) {
onClose();
return true;
}
if (event.key === KEYS.ENTER) {
if (hoveredFont?.value) {
onSelect(hoveredFont.value);
}
return true;
}
if (event.key === KEYS.ARROW_DOWN) {
if (hoveredFont?.next) {
onHover(hoveredFont.next.value);
} else if (filteredFonts[0]?.value) {
onHover(filteredFonts[0].value);
}
return true;
}
if (event.key === KEYS.ARROW_UP) {
if (hoveredFont?.prev) {
onHover(hoveredFont.prev.value);
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
onHover(filteredFonts[filteredFonts.length - 1].value);
}
return true;
}
};

View File

@@ -8,7 +8,7 @@
h3 {
margin: 1.5rem 0;
font-weight: 700;
font-weight: bold;
font-size: 1.125rem;
}
@@ -82,7 +82,7 @@
&__island {
h4 {
font-size: 1rem;
font-weight: 700;
font-weight: bold;
margin: 0;
margin-bottom: 0.625rem;
}

View File

@@ -458,10 +458,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]}
/>
<Shortcut
label={t("labels.showFonts")}
shortcuts={[getShortcutKey("Shift+F")]}
/>
<Shortcut
label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}

View File

@@ -30,13 +30,10 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.eraserRevert");
}
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) {
return t("hints.linearElementMulti");
if (!multiMode) {
return t("hints.linearElement");
}
if (activeTool.type === "arrow") {
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
}
return t("hints.linearElement");
return t("hints.linearElementMulti");
}
if (activeTool.type === "freedraw") {

View File

@@ -11,7 +11,7 @@
.library-actions-counter {
background-color: var(--color-primary);
color: var(--color-primary-light);
font-weight: 700;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -13,7 +13,7 @@
&__label {
color: var(--color-primary);
font-weight: 700;
font-weight: bold;
font-size: 1.125rem;
margin-bottom: 0.75rem;
}
@@ -62,7 +62,7 @@
&__header {
color: var(--color-primary);
font-size: 1.125rem;
font-weight: 700;
font-weight: bold;
margin-bottom: 0.75rem;
width: 100%;
padding-right: 4rem; // due to dropdown button

View File

@@ -1,96 +0,0 @@
import React, { type ReactNode } from "react";
import clsx from "clsx";
import * as Popover from "@radix-ui/react-popover";
import { useDevice } from "./App";
import { Island } from "./Island";
import { isInteractive } from "../utils";
interface PropertiesPopoverProps {
className?: string;
container: HTMLDivElement | null;
children: ReactNode;
style?: object;
onClose: () => void;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
onFocusOutside?: Popover.DismissableLayerProps["onFocusOutside"];
onPointerDownOutside?: Popover.DismissableLayerProps["onPointerDownOutside"];
}
export const PropertiesPopover = React.forwardRef<
HTMLDivElement,
PropertiesPopoverProps
>(
(
{
className,
container,
children,
style,
onClose,
onKeyDown,
onFocusOutside,
onPointerLeave,
onPointerDownOutside,
},
ref,
) => {
const device = useDevice();
return (
<Popover.Portal container={container}>
<Popover.Content
ref={ref}
className={clsx("focus-visible-none", className)}
data-prevent-outside-click
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
style={{
zIndex: "var(--zIndex-popup)",
}}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
onFocusOutside={onFocusOutside}
onPointerDownOutside={onPointerDownOutside}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
// return focus to excalidraw container unless
// user focuses an interactive element, such as a button, or
// enters the text editor by clicking on canvas with the text tool
if (container && !isInteractive(document.activeElement)) {
container.focus();
}
onClose();
}}
>
<Island padding={3} style={style}>
{children}
</Island>
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
/>
</Popover.Content>
</Popover.Portal>
);
},
);

View File

@@ -133,7 +133,7 @@
.required,
.error {
color: $oc-red-8;
font-weight: 700;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}

View File

@@ -1,48 +0,0 @@
.excalidraw {
--list-border-color: var(--color-gray-20);
.QuickSearch__wrapper {
position: relative;
height: 2.6rem; // added +0.1 due to Safari
border-bottom: 1px solid var(--list-border-color);
svg {
position: absolute;
top: 47.5%; // 50% is not exactly in the center of the input
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
}
&.theme--dark {
--list-border-color: var(--color-gray-80);
.QuickSearch__wrapper {
border-bottom: none;
}
}
.QuickSearch__input {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
border: 0 !important;
font-size: 0.875rem;
padding-left: 2.5rem !important;
padding-right: 0.75rem !important;
&::placeholder {
color: var(--color-gray-40);
}
&:focus {
box-shadow: none !important;
}
}
}

View File

@@ -1,28 +0,0 @@
import clsx from "clsx";
import React from "react";
import { searchIcon } from "./icons";
import "./QuickSearch.scss";
interface QuickSearchProps {
className?: string;
placeholder: string;
onChange: (term: string) => void;
}
export const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
({ className, placeholder, onChange }, ref) => {
return (
<div className={clsx("QuickSearch__wrapper", className)}>
{searchIcon}
<input
ref={ref}
className="QuickSearch__input"
type="text"
placeholder={placeholder}
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
/>
</div>
);
},
);

View File

@@ -1,21 +0,0 @@
.excalidraw {
.ScrollableList__wrapper {
position: static !important;
border: none;
font-size: 0.875rem;
overflow-y: auto;
& > .empty,
& > .hint {
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
font-size: 0.75rem;
color: var(--color-gray-60);
overflow: hidden;
text-align: center;
line-height: 150%;
}
}
}

View File

@@ -1,24 +0,0 @@
import clsx from "clsx";
import { Children } from "react";
import "./ScrollableList.scss";
interface ScrollableListProps {
className?: string;
placeholder: string;
children: React.ReactNode;
}
export const ScrollableList = ({
className,
placeholder,
children,
}: ScrollableListProps) => {
const isEmpty = !Children.count(children);
return (
<div className={clsx("ScrollableList__wrapper", className)} role="menu">
{isEmpty ? <div className="empty">{placeholder}</div> : children}
</div>
);
};

View File

@@ -1,81 +1,67 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { isArrowElement } from "../../element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getStepSizedValue, isPropertyEditable } from "./utils";
interface AngleProps {
element: ExcalidrawElement;
scene: Scene;
appState: AppState;
property: "angle";
elementsMap: ElementsMap;
}
const STEP_SIZE = 15;
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement && !isElbowArrow(origElement)) {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
const Angle = ({ element, elementsMap }: AngleProps) => {
const handleDegreeChange: DragInputCallbackType = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
}) => {
const origElement = originalElements[0];
if (origElement) {
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
mutateElement(element, {
angle: nextAngle,
});
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
return;
}
return;
const originalAngleInDegrees =
Math.round(radianToDegree(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(element, {
angle: nextAngle,
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
}
};
const originalAngleInDegrees =
Math.round(radianToDegree(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
}
};
const Angle = ({ element, scene, appState, property }: AngleProps) => {
return (
<DragInput
label="A"
@@ -84,9 +70,6 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
elements={[element]}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}
scene={scene}
appState={appState}
property={property}
/>
);
};

View File

@@ -1,16 +1,13 @@
import type { ExcalidrawElement } from "../../element/types";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface DimensionDragInputProps {
property: "width" | "height";
element: ExcalidrawElement;
scene: Scene;
appState: AppState;
elementsMap: ElementsMap;
}
const STEP_SIZE = 10;
@@ -18,106 +15,99 @@ const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
return element.type === "image";
};
const handleDimensionChange: DragInputCallbackType<
DimensionDragInputProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
const DimensionDragInput = ({
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
const aspectRatio = origElement.width / origElement.height;
element,
elementsMap,
}: DimensionDragInputProps) => {
const handleDimensionChange: DragInputCallbackType = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
}) => {
const origElement = originalElements[0];
if (origElement) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
const aspectRatio = origElement.width / origElement.height;
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
? nextValue
: keepAspectRatio
? nextValue * aspectRatio
: origElement.width,
MIN_WIDTH_OR_HEIGHT,
);
const nextHeight = Math.max(
property === "height"
? nextValue
: keepAspectRatio
? nextValue / aspectRatio
: origElement.height,
MIN_WIDTH_OR_HEIGHT,
);
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
? nextValue
: keepAspectRatio
? nextValue * aspectRatio
: origElement.width,
MIN_WIDTH_OR_HEIGHT,
);
const nextHeight = Math.max(
property === "height"
? nextValue
: keepAspectRatio
? nextValue / aspectRatio
: origElement.height,
MIN_WIDTH_OR_HEIGHT,
);
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
element,
origElement,
elementsMap,
originalElementsMap,
);
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
element,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
};
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeElement(
nextWidth,
nextHeight,
keepAspectRatio,
origElement,
elementsMap,
elements,
scene,
);
}
};
const DimensionDragInput = ({
property,
element,
scene,
appState,
}: DimensionDragInputProps) => {
const value =
Math.round((property === "width" ? element.width : element.height) * 100) /
100;
@@ -129,9 +119,6 @@ const DimensionDragInput = ({
dragInputCallback={handleDimensionChange}
value={value}
editable={isPropertyEditable(element, property)}
scene={scene}
appState={appState}
property={property}
/>
);
};

View File

@@ -3,54 +3,43 @@ import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { deepCopyElement } from "../../element/newElement";
import "./DragInput.scss";
import clsx from "clsx";
import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon";
import type { StatsInputProperty } from "./utils";
import { SMALLEST_DELTA } from "./utils";
import { StoreAction } from "../../store";
import type Scene from "../../scene/Scene";
import "./DragInput.scss";
import type { AppState } from "../../types";
import { cloneJSON } from "../../utils";
export type DragInputCallbackType<
P extends StatsInputProperty,
E = ExcalidrawElement,
> = (props: {
export type DragInputCallbackType = ({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
}: {
accumulatedChange: number;
instantChange: number;
originalElements: readonly E[];
originalElements: readonly ExcalidrawElement[];
originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean;
scene: Scene;
nextValue?: number;
property: P;
originalAppState: AppState;
}) => void;
interface StatsDragInputProps<
T extends StatsInputProperty,
E = ExcalidrawElement,
> {
interface StatsDragInputProps {
label: string | React.ReactNode;
icon?: React.ReactNode;
value: number | "Mixed";
elements: readonly E[];
elements: readonly ExcalidrawElement[];
editable?: boolean;
shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType<T, E>;
property: T;
scene: Scene;
appState: AppState;
dragInputCallback: DragInputCallbackType;
}
const StatsDragInput = <
T extends StatsInputProperty,
E extends ExcalidrawElement = ExcalidrawElement,
>({
const StatsDragInput = ({
label,
icon,
dragInputCallback,
@@ -58,48 +47,19 @@ const StatsDragInput = <
elements,
editable = true,
shouldKeepAspectRatio,
property,
scene,
appState,
}: StatsDragInputProps<T, E>) => {
}: StatsDragInputProps) => {
const app = useApp();
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState(value.toString());
const stateRef = useRef<{
originalAppState: AppState;
originalElements: readonly E[];
lastUpdatedValue: string;
updatePending: boolean;
}>(null!);
if (!stateRef.current) {
stateRef.current = {
originalAppState: cloneJSON(appState),
originalElements: elements,
lastUpdatedValue: inputValue,
updatePending: false,
};
}
useEffect(() => {
const inputValue = value.toString();
setInputValue(inputValue);
stateRef.current.lastUpdatedValue = inputValue;
}, [value]);
setInputValue(value.toString());
}, [value, elements]);
const handleInputValue = (
updatedValue: string,
elements: readonly E[],
appState: AppState,
) => {
if (!stateRef.current.updatePending) {
return false;
}
stateRef.current.updatePending = false;
const parsed = Number(updatedValue);
const handleInputValue = (v: string) => {
const parsed = Number(v);
if (isNaN(parsed)) {
setInputValue(value.toString());
return;
@@ -114,7 +74,6 @@ const StatsDragInput = <
// than the smallest delta allowed, which is 0.01
// reason: idempotent to avoid unnecessary
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
stateRef.current.lastUpdatedValue = updatedValue;
dragInputCallback({
accumulatedChange: 0,
instantChange: 0,
@@ -122,10 +81,7 @@ const StatsDragInput = <
originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false,
scene,
nextValue: rounded,
property,
originalAppState: appState,
});
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
@@ -141,28 +97,12 @@ const StatsDragInput = <
return () => {
const nextValue = input?.value;
if (nextValue) {
handleInputValueRef.current(
nextValue,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
handleInputValueRef.current(nextValue);
}
};
}, [
// we need to track change of `editable` state as mount/unmount
// because react doesn't trigger `blur` when a an input is blurred due
// to being disabled (https://github.com/facebook/react/issues/9142).
// As such, if we keep rendering disabled inputs, then change in selection
// to an element that has a given property as non-editable would not trigger
// blur/unmount and wouldn't update the value.
editable,
]);
}, []);
if (!editable) {
return null;
}
return (
return editable ? (
<div
className={clsx("drag-input-container", !editable && "disabled")}
data-testid={label}
@@ -182,25 +122,30 @@ const StatsDragInput = <
y: number;
} | null = null;
let originalElements: ExcalidrawElement[] | null = null;
let originalElementsMap: Map<string, ExcalidrawElement> | null =
app.scene
.getNonDeletedElements()
.reduce((acc: ElementsMap, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map());
let originalElements: readonly E[] | null = elements.map(
(element) => originalElementsMap!.get(element.id) as E,
);
const originalAppState: AppState = cloneJSON(appState);
null;
let accumulatedChange: number | null = null;
document.body.classList.add("excalidraw-cursor-resize");
const onPointerMove = (event: PointerEvent) => {
if (!originalElementsMap) {
originalElementsMap = app.scene
.getNonDeletedElements()
.reduce((acc, element) => {
acc.set(element.id, deepCopyElement(element));
return acc;
}, new Map() as ElementsMap);
}
if (!originalElements) {
originalElements = elements.map(
(element) => originalElementsMap!.get(element.id)!,
);
}
if (!accumulatedChange) {
accumulatedChange = 0;
}
@@ -208,7 +153,6 @@ const StatsDragInput = <
if (
lastPointer &&
originalElementsMap !== null &&
originalElements !== null &&
accumulatedChange !== null
) {
const instantChange = event.clientX - lastPointer.x;
@@ -221,9 +165,6 @@ const StatsDragInput = <
originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
});
}
@@ -275,7 +216,7 @@ const StatsDragInput = <
eventTarget instanceof HTMLInputElement &&
event.key === KEYS.ENTER
) {
handleInputValue(eventTarget.value, elements, appState);
handleInputValue(eventTarget.value);
app.focusContainer();
}
}
@@ -283,28 +224,23 @@ const StatsDragInput = <
ref={inputRef}
value={inputValue}
onChange={(event) => {
stateRef.current.updatePending = true;
setInputValue(event.target.value);
}}
onFocus={(event) => {
event.target.select();
stateRef.current.originalElements = elements;
stateRef.current.originalAppState = cloneJSON(appState);
}}
onBlur={(event) => {
if (!inputValue) {
setInputValue(value.toString());
} else if (editable) {
handleInputValue(
event.target.value,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
handleInputValue(event.target.value);
}
}}
disabled={!editable}
/>
</div>
) : (
<></>
);
};

View File

@@ -1,97 +1,73 @@
import type {
ExcalidrawElement,
ExcalidrawTextElement,
} from "../../element/types";
import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
import { refreshTextDimensions } from "../../element/newElement";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { mutateElement } from "../../element/mutateElement";
import { getStepSizedValue } from "./utils";
import { fontSizeIcon } from "../icons";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { isTextElement, redrawTextBoundingBox } from "../../element";
import { hasBoundTextElement } from "../../element/typeChecks";
import { getBoundTextElement } from "../../element/textElement";
interface FontSizeProps {
element: ExcalidrawElement;
scene: Scene;
appState: AppState;
property: "fontSize";
element: ExcalidrawTextElement;
elementsMap: ElementsMap;
}
const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4;
const handleFontSizeChange: DragInputCallbackType<
FontSizeProps["property"],
ExcalidrawTextElement
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const FontSize = ({ element, elementsMap }: FontSizeProps) => {
const handleFontSizeChange: DragInputCallbackType = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
}) => {
const origElement = originalElements[0];
if (origElement) {
if (nextValue !== undefined) {
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
const origElement = originalElements[0];
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement || !isTextElement(latestElement)) {
return;
}
const newElement = {
...element,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(element, {
...updates,
fontSize: nextFontSize,
});
return;
}
let nextFontSize;
if (nextValue !== undefined) {
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
} else if (origElement.type === "text") {
const originalFontSize = Math.round(origElement.fontSize);
const changeInFontSize = Math.round(accumulatedChange);
nextFontSize = Math.max(
originalFontSize + changeInFontSize,
MIN_FONT_SIZE,
);
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
if (origElement.type === "text") {
const originalFontSize = Math.round(origElement.fontSize);
const changeInFontSize = Math.round(accumulatedChange);
let nextFontSize = Math.max(
originalFontSize + changeInFontSize,
MIN_FONT_SIZE,
);
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
}
const newElement = {
...element,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(element, {
...updates,
fontSize: nextFontSize,
});
}
}
if (nextFontSize) {
mutateElement(latestElement, {
fontSize: nextFontSize,
});
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
scene.getNonDeletedElementsMap(),
);
}
}
};
const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
const _element = isTextElement(element)
? element
: hasBoundTextElement(element)
? getBoundTextElement(element, scene.getNonDeletedElementsMap())
: null;
if (!_element) {
return null;
}
};
return (
<StatsDragInput
label="F"
value={Math.round(_element.fontSize * 10) / 10}
elements={[_element]}
value={Math.round(element.fontSize * 10) / 10}
elements={[element]}
dragInputCallback={handleFontSizeChange}
icon={fontSizeIcon}
appState={appState}
scene={scene}
property={property}
/>
);
};

View File

@@ -1,7 +1,7 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { isInGroup } from "../../groups";
import { degreeToRadian, radianToDegree } from "../../math";
import type Scene from "../../scene/Scene";
@@ -9,102 +9,84 @@ import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { AppState } from "../../types";
interface MultiAngleProps {
elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
scene: Scene;
appState: AppState;
property: "angle";
}
const STEP_SIZE = 15;
const handleDegreeChange: DragInputCallbackType<
MultiAngleProps["property"]
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const editableLatestIndividualElements = originalElements
.map((el) => elementsMap.get(el.id))
.filter((el) => el && !isInGroup(el) && isPropertyEditable(el, property));
const editableOriginalIndividualElements = originalElements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, property),
);
const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
const handleDegreeChange: DragInputCallbackType = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
}) => {
const editableLatestIndividualElements = elements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
);
const editableOriginalIndividualElements = originalElements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
);
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
for (const element of editableLatestIndividualElements) {
if (!element) {
continue;
for (const element of editableLatestIndividualElements) {
mutateElement(
element,
{
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
}
}
scene.triggerUpdate();
return;
}
for (let i = 0; i < editableLatestIndividualElements.length; i++) {
const latestElement = editableLatestIndividualElements[i];
const originalElement = editableOriginalIndividualElements[i];
const originalAngleInDegrees =
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(
element,
latestElement,
{
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) {
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
}
}
scene.triggerUpdate();
};
return;
}
for (let i = 0; i < editableLatestIndividualElements.length; i++) {
const latestElement = editableLatestIndividualElements[i];
if (!latestElement) {
continue;
}
const originalElement = editableOriginalIndividualElements[i];
const originalAngleInDegrees =
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
}
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
mutateElement(
latestElement,
{
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }, false);
}
}
scene.triggerUpdate();
};
const MultiAngle = ({
elements,
scene,
appState,
property,
}: MultiAngleProps) => {
const editableLatestIndividualElements = elements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
);
@@ -125,9 +107,6 @@ const MultiAngle = ({
elements={elements}
dragInputCallback={handleDegreeChange}
editable={editable}
appState={appState}
scene={scene}
property={property}
/>
);
};

View File

@@ -7,16 +7,12 @@ import {
getBoundTextElement,
handleBindTextResize,
} from "../../element/textElement";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import type Scene from "../../scene/Scene";
import type { AppState, Point } from "../../types";
import type { Point } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
@@ -24,10 +20,9 @@ import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
interface MultiDimensionProps {
property: "width" | "height";
elements: readonly ExcalidrawElement[];
elementsMap: NonDeletedSceneElementsMap;
elementsMap: ElementsMap;
atomicUnits: AtomicUnit[];
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
@@ -64,12 +59,10 @@ const resizeElementInGroup = (
scale: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement(
@@ -78,8 +71,8 @@ const resizeElementInGroup = (
);
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, scene, {
oldSize: { width: oldWidth, height: oldHeight },
updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -109,9 +102,8 @@ const resizeGroup = (
property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
// keep aspect ratio for groups
if (property === "width") {
@@ -135,223 +127,16 @@ const resizeGroup = (
origElement,
elementsMap,
originalElementsMap,
scene,
);
}
};
const handleDimensionChange: DragInputCallbackType<
MultiDimensionProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
originalAppState,
shouldChangeByStepSize,
nextValue,
scene,
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const latestElements = elementsInUnit.map((el) => el.latest!);
const originalElements = elementsInUnit.map((el) => el.original!);
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const aspectRatio = initialWidth / initialHeight;
const nextWidth = Math.max(
MIN_WIDTH_OR_HEIGHT,
property === "width" ? Math.max(0, nextValue) : initialWidth,
);
const nextHeight = Math.max(
MIN_WIDTH_OR_HEIGHT,
property === "height" ? Math.max(0, nextValue) : initialHeight,
);
resizeGroup(
nextWidth,
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
scene,
);
} else {
const [el] = elementsInUnit;
const latestElement = el?.latest;
const origElement = el?.original;
if (
latestElement &&
origElement &&
isPropertyEditable(latestElement, property)
) {
let nextWidth =
property === "width" ? Math.max(0, nextValue) : latestElement.width;
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight =
property === "height"
? Math.max(0, nextValue)
: latestElement.height;
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
nextWidth,
nextHeight,
false,
origElement,
elementsMap,
elements,
scene,
false,
);
}
}
}
scene.triggerUpdate();
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const latestElements = elementsInUnit.map((el) => el.latest!);
const originalElements = elementsInUnit.map((el) => el.original!);
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const aspectRatio = initialWidth / initialHeight;
let nextWidth = Math.max(0, initialWidth + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, initialHeight + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeGroup(
nextWidth,
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
scene,
);
} else {
const [el] = elementsInUnit;
const latestElement = el?.latest;
const origElement = el?.original;
if (
latestElement &&
origElement &&
isPropertyEditable(latestElement, property)
) {
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
nextWidth,
nextHeight,
false,
origElement,
elementsMap,
elements,
scene,
);
}
}
}
scene.triggerUpdate();
};
const MultiDimension = ({
property,
elements,
elementsMap,
atomicUnits,
scene,
appState,
}: MultiDimensionProps) => {
const sizes = useMemo(
() =>
@@ -382,6 +167,202 @@ const MultiDimension = ({
const editable = sizes.length > 0;
const handleDimensionChange: DragInputCallbackType = ({
accumulatedChange,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
}) => {
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const latestElements = elementsInUnit.map((el) => el.latest!);
const originalElements = elementsInUnit.map((el) => el.original!);
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const aspectRatio = initialWidth / initialHeight;
const nextWidth = Math.max(
MIN_WIDTH_OR_HEIGHT,
property === "width" ? Math.max(0, nextValue) : initialWidth,
);
const nextHeight = Math.max(
MIN_WIDTH_OR_HEIGHT,
property === "height" ? Math.max(0, nextValue) : initialHeight,
);
resizeGroup(
nextWidth,
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
);
} else {
const [el] = elementsInUnit;
const latestElement = el?.latest;
const origElement = el?.original;
if (
latestElement &&
origElement &&
isPropertyEditable(latestElement, property)
) {
let nextWidth =
property === "width"
? Math.max(0, nextValue)
: latestElement.width;
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight =
property === "height"
? Math.max(0, nextValue)
: latestElement.height;
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
originalElementsMap,
false,
);
}
}
}
scene.triggerUpdate();
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const latestElements = elementsInUnit.map((el) => el.latest!);
const originalElements = elementsInUnit.map((el) => el.original!);
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
const initialWidth = x2 - x1;
const initialHeight = y2 - y1;
const aspectRatio = initialWidth / initialHeight;
let nextWidth = Math.max(0, initialWidth + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, initialHeight + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeGroup(
nextWidth,
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
property,
latestElements,
originalElements,
elementsMap,
originalElementsMap,
);
} else {
const [el] = elementsInUnit;
const latestElement = el?.latest;
const origElement = el?.original;
if (
latestElement &&
origElement &&
isPropertyEditable(latestElement, property)
) {
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
originalElementsMap,
);
}
}
}
scene.triggerUpdate();
};
return (
<DragInput
label={property === "width" ? "W" : "H"}
@@ -389,9 +370,6 @@ const MultiDimension = ({
dragInputCallback={handleDimensionChange}
value={value}
editable={editable}
appState={appState}
property={property}
scene={scene}
/>
);
};

View File

@@ -1,10 +1,10 @@
import { isTextElement, redrawTextBoundingBox } from "../../element";
import { isTextElement, refreshTextDimensions } from "../../element";
import { mutateElement } from "../../element/mutateElement";
import { hasBoundTextElement } from "../../element/typeChecks";
import { isBoundToContainer } from "../../element/typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawTextElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { isInGroup } from "../../groups";
import type Scene from "../../scene/Scene";
@@ -12,87 +12,62 @@ import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue } from "./utils";
import type { AppState } from "../../types";
import { getBoundTextElement } from "../../element/textElement";
interface MultiFontSizeProps {
elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
scene: Scene;
elementsMap: NonDeletedSceneElementsMap;
appState: AppState;
property: "fontSize";
}
const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4;
const getApplicableTextElements = (
elements: readonly (ExcalidrawElement | undefined)[],
elementsMap: NonDeletedSceneElementsMap,
) =>
elements.reduce(
(acc: ExcalidrawTextElement[], el) => {
if (!el || isInGroup(el)) {
return acc;
}
if (isTextElement(el)) {
acc.push(el);
return acc;
}
if (hasBoundTextElement(el)) {
const boundTextElement = getBoundTextElement(el, elementsMap);
if (boundTextElement) {
acc.push(boundTextElement);
return acc;
}
}
return acc;
},
[],
);
const handleFontSizeChange: DragInputCallbackType<
MultiFontSizeProps["property"],
ExcalidrawTextElement
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
const MultiFontSize = ({
elements,
elementsMap,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const latestTextElements = originalElements.map((el) =>
elementsMap.get(el.id),
}: MultiFontSizeProps) => {
const latestTextElements = elements.filter(
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
) as ExcalidrawTextElement[];
const fontSizes = latestTextElements.map(
(textEl) => Math.round(textEl.fontSize * 10) / 10,
);
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
const editable = fontSizes.length > 0;
let nextFontSize;
const handleFontSizeChange: DragInputCallbackType = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
}) => {
if (nextValue) {
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
if (nextValue) {
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
for (const textElement of latestTextElements) {
mutateElement(
textElement,
{
for (const textElement of latestTextElements) {
const newElement = {
...textElement,
fontSize: nextFontSize,
},
false,
);
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(
textElement,
{
...updates,
fontSize: nextFontSize,
},
false,
);
}
redrawTextBoundingBox(
textElement,
scene.getContainerElement(textElement),
elementsMap,
false,
);
scene.triggerUpdate();
return;
}
scene.triggerUpdate();
} else {
const originalTextElements = originalElements as ExcalidrawTextElement[];
const originalTextElements = originalElements.filter(
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
) as ExcalidrawTextElement[];
for (let i = 0; i < latestTextElements.length; i++) {
const latestElement = latestTextElements[i];
@@ -107,56 +82,32 @@ const handleFontSizeChange: DragInputCallbackType<
if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
}
const newElement = {
...latestElement,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(
latestElement,
{
...updates,
fontSize: nextFontSize,
},
false,
);
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
elementsMap,
false,
);
}
scene.triggerUpdate();
}
};
const MultiFontSize = ({
elements,
scene,
appState,
property,
elementsMap,
}: MultiFontSizeProps) => {
const latestTextElements = getApplicableTextElements(elements, elementsMap);
if (!latestTextElements.length) {
return null;
}
const fontSizes = latestTextElements.map(
(textEl) => Math.round(textEl.fontSize * 10) / 10,
);
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
const editable = fontSizes.length > 0;
};
return (
<StatsDragInput
label="F"
icon={fontSizeIcon}
elements={latestTextElements}
elements={elements}
dragInputCallback={handleFontSizeChange}
value={value}
editable={editable}
scene={scene}
property={property}
appState={appState}
/>
);
};

View File

@@ -1,19 +1,13 @@
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { getCommonBounds, isTextElement } from "../../element";
import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
interface MultiPositionProps {
property: "x" | "y";
@@ -21,7 +15,6 @@ interface MultiPositionProps {
elementsMap: ElementsMap;
atomicUnits: AtomicUnit[];
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
@@ -32,12 +25,12 @@ const moveElements = (
changeInTopY: number,
elements: readonly ExcalidrawElement[],
originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i];
const latestElement = elements[i];
const [cx, cy] = [
origElement.x + origElement.width / 2,
@@ -60,10 +53,9 @@ const moveElements = (
moveElement(
newTopLeftX,
newTopLeftY,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@@ -73,23 +65,18 @@ const moveElements = (
const moveGroupTo = (
nextX: number,
nextY: number,
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
const [x1, y1, ,] = getCommonBounds(originalElements);
const offsetX = nextX - x1;
const offsetY = nextY - y1;
for (let i = 0; i < originalElements.length; i++) {
for (let i = 0; i < latestElements.length; i++) {
const origElement = originalElements[i];
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
continue;
}
const latestElement = latestElements[i];
// bound texts are moved with their containers
if (!isTextElement(latestElement) || !latestElement.containerId) {
@@ -109,10 +96,9 @@ const moveGroupTo = (
moveElement(
topLeftX + offsetX,
topLeftY + offsetY,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@@ -120,116 +106,12 @@ const moveGroupTo = (
}
};
const handlePositionChange: DragInputCallbackType<
MultiPositionProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits(
originalElements,
originalAppState,
)) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const [x1, y1, ,] = getCommonBounds(
elementsInUnit.map((el) => el.latest!),
);
const newTopLeftX = property === "x" ? nextValue : x1;
const newTopLeftY = property === "y" ? nextValue : y1;
moveGroupTo(
newTopLeftX,
newTopLeftY,
elementsInUnit.map((el) => el.original),
elementsMap,
elements,
originalElementsMap,
scene,
);
} else {
const origElement = elementsInUnit[0]?.original;
const latestElement = elementsInUnit[0]?.latest;
if (
origElement &&
latestElement &&
isPropertyEditable(latestElement, property)
) {
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
}
}
}
scene.triggerUpdate();
return;
}
const change = shouldChangeByStepSize
? getStepSizedValue(accumulatedChange, STEP_SIZE)
: accumulatedChange;
const changeInTopX = property === "x" ? change : 0;
const changeInTopY = property === "y" ? change : 0;
moveElements(
property,
changeInTopX,
changeInTopY,
originalElements,
originalElements,
elementsMap,
originalElementsMap,
scene,
);
scene.triggerUpdate();
};
const MultiPosition = ({
property,
elements,
elementsMap,
atomicUnits,
scene,
appState,
}: MultiPositionProps) => {
const positions = useMemo(
() =>
@@ -255,15 +137,101 @@ const MultiPosition = ({
const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
const handlePositionChange: DragInputCallbackType = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
}) => {
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit(
atomicUnit,
elementsMap,
originalElementsMap,
);
if (elementsInUnit.length > 1) {
const [x1, y1, ,] = getCommonBounds(
elementsInUnit.map((el) => el.latest!),
);
const newTopLeftX = property === "x" ? nextValue : x1;
const newTopLeftY = property === "y" ? nextValue : y1;
moveGroupTo(
newTopLeftX,
newTopLeftY,
elementsInUnit.map((el) => el.latest),
elementsInUnit.map((el) => el.original),
elementsMap,
originalElementsMap,
);
} else {
const origElement = elementsInUnit[0]?.original;
const latestElement = elementsInUnit[0]?.latest;
if (
origElement &&
latestElement &&
isPropertyEditable(latestElement, property)
) {
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
latestElement,
origElement,
elementsMap,
originalElementsMap,
false,
);
}
}
}
scene.triggerUpdate();
return;
}
const change = shouldChangeByStepSize
? getStepSizedValue(accumulatedChange, STEP_SIZE)
: accumulatedChange;
const changeInTopX = property === "x" ? change : 0;
const changeInTopY = property === "y" ? change : 0;
moveElements(
property,
changeInTopX,
changeInTopY,
elements,
originalElements,
elementsMap,
originalElementsMap,
);
scene.triggerUpdate();
};
return (
<StatsDragInput
label={property === "x" ? "X" : "Y"}
elements={elements}
dragInputCallback={handlePositionChange}
value={value}
property={property}
scene={scene}
appState={appState}
/>
);
};

View File

@@ -3,97 +3,16 @@ import { rotate } from "../../math";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface PositionProps {
property: "x" | "y";
element: ExcalidrawElement;
elementsMap: ElementsMap;
scene: Scene;
appState: AppState;
}
const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
return;
}
const changeInTopX = property === "x" ? accumulatedChange : 0;
const changeInTopY = property === "y" ? accumulatedChange : 0;
const newTopLeftX =
property === "x"
? Math.round(
shouldChangeByStepSize
? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
: topLeftX + changeInTopX,
)
: topLeftX;
const newTopLeftY =
property === "y"
? Math.round(
shouldChangeByStepSize
? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
: topLeftY + changeInTopY,
)
: topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
};
const Position = ({
property,
element,
elementsMap,
scene,
appState,
}: PositionProps) => {
const Position = ({ property, element, elementsMap }: PositionProps) => {
const [topLeftX, topLeftY] = rotate(
element.x,
element.y,
@@ -104,15 +23,77 @@ const Position = ({
const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
const handlePositionChange: DragInputCallbackType = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
}) => {
const origElement = originalElements[0];
const [cx, cy] = [
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
origElement.angle,
);
if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
element,
origElement,
elementsMap,
originalElementsMap,
);
return;
}
const changeInTopX = property === "x" ? accumulatedChange : 0;
const changeInTopY = property === "y" ? accumulatedChange : 0;
const newTopLeftX =
property === "x"
? Math.round(
shouldChangeByStepSize
? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
: topLeftX + changeInTopX,
)
: topLeftX;
const newTopLeftY =
property === "y"
? Math.round(
shouldChangeByStepSize
? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
: topLeftY + changeInTopY,
)
: topLeftY;
moveElement(
newTopLeftX,
newTopLeftY,
element,
origElement,
elementsMap,
originalElementsMap,
);
};
return (
<StatsDragInput
label={property === "x" ? "X" : "Y"}
elements={[element]}
dragInputCallback={handlePositionChange}
scene={scene}
value={value}
property={property}
appState={appState}
/>
);
};

View File

@@ -11,7 +11,12 @@ import Angle from "./Angle";
import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension";
import { elementsAreInSameGroup } from "../../groups";
import {
elementsAreInSameGroup,
getElementsInGroup,
getSelectedGroupIds,
isInGroup,
} from "../../groups";
import MultiAngle from "./MultiAngle";
import MultiFontSize from "./MultiFontSize";
import Position from "./Position";
@@ -19,9 +24,8 @@ import MultiPosition from "./MultiPosition";
import Collapsible from "./Collapsible";
import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import type { AtomicUnit } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
interface StatsProps {
scene: Scene;
@@ -102,7 +106,21 @@ export const StatsInner = memo(
);
const atomicUnits = useMemo(() => {
return getAtomicUnits(selectedElements, appState);
const selectedGroupIds = getSelectedGroupIds(appState);
const _atomicUnits = selectedGroupIds.map((gid) => {
return getElementsInGroup(selectedElements, gid).reduce((acc, el) => {
acc[el.id] = true;
return acc;
}, {} as AtomicUnit);
});
selectedElements
.filter((el) => !isInGroup(el))
.forEach((el) => {
_atomicUnits.push({
[el.id]: true,
});
});
return _atomicUnits;
}, [selectedElements, appState]);
return (
@@ -188,42 +206,32 @@ export const StatsInner = memo(
element={singleElement}
property="x"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<Position
element={singleElement}
property="y"
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<Dimension
property="width"
element={singleElement}
scene={scene}
appState={appState}
elementsMap={elementsMap}
/>
<Dimension
property="height"
element={singleElement}
scene={scene}
appState={appState}
elementsMap={elementsMap}
/>
{!isElbowArrow(singleElement) && (
<Angle
property="angle"
<Angle
element={singleElement}
elementsMap={elementsMap}
/>
{singleElement.type === "text" && (
<FontSize
element={singleElement}
scene={scene}
appState={appState}
elementsMap={elementsMap}
/>
)}
<FontSize
property="fontSize"
element={singleElement}
scene={scene}
appState={appState}
/>
</div>
</div>
)}
@@ -246,7 +254,6 @@ export const StatsInner = memo(
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiPosition
property="y"
@@ -254,7 +261,6 @@ export const StatsInner = memo(
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiDimension
property="width"
@@ -262,7 +268,6 @@ export const StatsInner = memo(
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiDimension
property="height"
@@ -270,20 +275,16 @@ export const StatsInner = memo(
elementsMap={elementsMap}
atomicUnits={atomicUnits}
scene={scene}
appState={appState}
/>
<MultiAngle
property="angle"
elements={multipleElements}
elementsMap={elementsMap}
scene={scene}
appState={appState}
/>
<MultiFontSize
property="fontSize"
elements={multipleElements}
scene={scene}
appState={appState}
elementsMap={elementsMap}
scene={scene}
/>
</div>
</div>

View File

@@ -11,11 +11,10 @@ import * as StaticScene from "../../renderer/staticScene";
import { vi } from "vitest";
import { reseed } from "../../random";
import { setDateTimeForTests } from "../../utils";
import { Excalidraw, mutateElement } from "../..";
import { Excalidraw } from "../..";
import { t } from "../../i18n";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
} from "../../element/types";
import { degreeToRadian, rotate } from "../../math";
@@ -24,7 +23,6 @@ import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups";
import React from "react";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -32,21 +30,11 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = null;
const editInput = (input: HTMLInputElement, value: string) => {
input.focus();
fireEvent.change(input, { target: { value } });
input.blur();
};
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
return properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
);
}
@@ -63,9 +51,11 @@ const testInputProperty = (
const input = getStatsProperty(label)?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input).not.toBeNull();
expect(input.value).toBe(initialValue.toString());
editInput(input, String(nextValue));
input?.focus();
input.value = nextValue.toString();
input?.blur();
if (property === "angle") {
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
} else if (property === "fontSize" && isTextElement(element)) {
@@ -101,92 +91,6 @@ describe("step sized value", () => {
});
});
describe("binding with linear elements", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(19);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
UI.clickTool("arrow");
mouse.down(5, 0);
mouse.up(300, 50);
elementStats = stats?.querySelector("#elementStats");
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should remain bound to linear element on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
editInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
editInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null);
});
it("should unbind linear element on large position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
editInput(inputX, String("254"));
expect(linear.startBinding).toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
editInput(inputAngle, String("45"));
expect(linear.startBinding).toBe(null);
});
});
// single element
describe("stats for a generic element", () => {
beforeEach(async () => {
@@ -223,8 +127,8 @@ describe("stats for a generic element", () => {
});
it("should open stats", () => {
expect(stats).toBeDefined();
expect(elementStats).toBeDefined();
expect(stats).not.toBeNull();
expect(elementStats).not.toBeNull();
// title
const title = elementStats?.querySelector("h3");
@@ -232,18 +136,18 @@ describe("stats for a generic element", () => {
// element type
const elementType = elementStats?.querySelector(".elementType");
expect(elementType).toBeDefined();
expect(elementType).not.toBeNull();
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
// properties
const properties = elementStats?.querySelector(".statsItem");
expect(properties?.childNodes).toBeDefined();
expect(properties?.childNodes).not.toBeNull();
["X", "Y", "W", "H", "A"].forEach((label) => () => {
expect(
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
),
).toBeDefined();
).not.toBeNull();
});
});
@@ -266,15 +170,19 @@ describe("stats for a generic element", () => {
const input = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input).not.toBeNull();
expect(input.value).toBe(rectangle.width.toString());
editInput(input, "123.123");
input?.focus();
input.value = "123.123";
input?.blur();
expect(h.elements.length).toBe(1);
expect(rectangle.id).toBe(rectangleId);
expect(input.value).toBe("123.12");
expect(rectangle.width).toBe(123.12);
editInput(input, "88.98766");
input?.focus();
input.value = "88.98766";
input?.blur();
expect(input.value).toBe("88.99");
expect(rectangle.width).toBe(88.99);
});
@@ -425,9 +333,11 @@ describe("stats for a non-generic element", () => {
const input = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input).not.toBeNull();
expect(input.value).toBe(text.fontSize.toString());
editInput(input, "36");
input?.focus();
input.value = "36";
input?.blur();
expect(text.fontSize).toBe(36);
// cannot change width or height
@@ -437,7 +347,9 @@ describe("stats for a non-generic element", () => {
expect(height).toBeUndefined();
// min font size is 4
editInput(input, "0");
input.focus();
input.value = "0";
input.blur();
expect(text.fontSize).not.toBe(0);
expect(text.fontSize).toBe(4);
});
@@ -458,7 +370,7 @@ describe("stats for a non-generic element", () => {
elementStats = stats?.querySelector("#elementStats");
expect(elementStats).toBeDefined();
expect(elementStats).not.toBeNull();
// cannot change angle
const angle = getStatsProperty("A")?.querySelector(".drag-input");
@@ -479,7 +391,7 @@ describe("stats for a non-generic element", () => {
},
});
elementStats = stats?.querySelector("#elementStats");
expect(elementStats).toBeDefined();
expect(elementStats).not.toBeNull();
const widthToHeight = image.width / image.height;
// when width or height is changed, the aspect ratio is preserved
@@ -491,35 +403,6 @@ describe("stats for a non-generic element", () => {
expect(image.height).toBe(80);
expect(image.width / image.height).toBe(widthToHeight);
});
it("should display fontSize for bound text", () => {
const container = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
const text = API.createElement({
type: "text",
width: 200,
height: 100,
containerId: container.id,
fontSize: 20,
});
mutateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
h.elements = [container, text];
API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
editInput(fontSize, "40");
expect(text.fontSize).toBe(40);
});
});
// multiple elements
@@ -588,12 +471,16 @@ describe("stats for multiple elements", () => {
) as HTMLInputElement;
expect(angle.value).toBe("0");
editInput(width, "250");
width.focus();
width.value = "250";
width.blur();
h.elements.forEach((el) => {
expect(el.width).toBe(250);
});
editInput(height, "450");
height.focus();
height.value = "450";
height.blur();
h.elements.forEach((el) => {
expect(el.height).toBe(450);
});
@@ -614,6 +501,7 @@ describe("stats for multiple elements", () => {
mouse.up(200, 100);
const frame = API.createElement({
id: "id0",
type: "frame",
x: 150,
width: 150,
@@ -636,34 +524,38 @@ describe("stats for multiple elements", () => {
const width = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(width).not.toBeNull();
expect(width.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
expect(height).not.toBeNull();
expect(height.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(angle).toBeDefined();
expect(angle).not.toBeNull();
expect(angle.value).toBe("0");
const fontSize = getStatsProperty("F")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(fontSize).toBeDefined();
expect(fontSize).not.toBeNull();
// changing width does not affect text
editInput(width, "200");
width.focus();
width.value = "200";
width.blur();
expect(rectangle?.width).toBe(200);
expect(frame.width).toBe(200);
expect(text?.width).not.toBe(200);
editInput(angle, "40");
angle.focus();
angle.value = "40";
angle.blur();
const angleInRadian = degreeToRadian(40);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
@@ -700,10 +592,12 @@ describe("stats for multiple elements", () => {
".drag-input",
) as HTMLInputElement;
expect(x).toBeDefined();
expect(x).not.toBeNull();
expect(Number(x.value)).toBe(x1);
editInput(x, "300");
x.focus();
x.value = "300";
x.blur();
expect(h.elements[0].x).toBe(300);
expect(h.elements[1].x).toBe(400);
@@ -713,10 +607,12 @@ describe("stats for multiple elements", () => {
".drag-input",
) as HTMLInputElement;
expect(y).toBeDefined();
expect(y).not.toBeNull();
expect(Number(y.value)).toBe(y1);
editInput(y, "200");
y.focus();
y.value = "200";
y.blur();
expect(h.elements[0].y).toBe(200);
expect(h.elements[1].y).toBe(300);
@@ -725,29 +621,35 @@ describe("stats for multiple elements", () => {
const width = getStatsProperty("W")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(width).toBeDefined();
expect(width).not.toBeNull();
expect(Number(width.value)).toBe(200);
const height = getStatsProperty("H")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(height).toBeDefined();
expect(height).not.toBeNull();
expect(Number(height.value)).toBe(200);
editInput(width, "400");
width.focus();
width.value = "400";
width.blur();
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
let newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(400, 4);
editInput(width, "300");
width.focus();
width.value = "300";
width.blur();
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(300, 4);
editInput(height, "500");
height.focus();
height.value = "500";
height.blur();
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
const newGroupHeight = y2 - y1;

View File

@@ -1,7 +1,4 @@
import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "../../element/binding";
import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
@@ -14,35 +11,15 @@ import {
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "../../element/typeChecks";
import { isFrameLikeElement, isTextElement } from "../../element/typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import {
getSelectedGroupIds,
getElementsInGroup,
isInGroup,
} from "../../groups";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty =
| "x"
| "y"
| "width"
| "height"
| "angle"
| "fontSize";
export const SMALLEST_DELTA = 0.01;
export const isPropertyEditable = (
@@ -123,16 +100,12 @@ export const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
elementsMap: ElementsMap,
originalElementsMap: Map<string, ExcalidrawElement>,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
@@ -149,8 +122,6 @@ export const resizeElement = (
nextHeight = Math.max(nextHeight, minHeight);
}
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(
latestElement,
{
@@ -169,12 +140,6 @@ export const resizeElement = (
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) {
boundTextFont = {
@@ -198,8 +163,11 @@ export const resizeElement = (
}
}
updateBoundElements(latestElement, elementsMap, scene, {
oldSize: { width: oldWidth, height: oldHeight },
updateBoundElements(latestElement, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement && boundTextFont) {
@@ -213,17 +181,12 @@ export const resizeElement = (
export const moveElement = (
newTopLeftX: number,
newTopLeftY: number,
latestElement: ExcalidrawElement,
originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(originalElement.id);
if (!latestElement) {
return;
}
const [cx, cy] = [
originalElement.x + originalElement.width / 2,
originalElement.y + originalElement.height / 2,
@@ -255,7 +218,6 @@ export const moveElement = (
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(
originalElement,
@@ -274,48 +236,3 @@ export const moveElement = (
);
}
};
export const getAtomicUnits = (
targetElements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedGroupIds = getSelectedGroupIds(appState);
const _atomicUnits = selectedGroupIds.map((gid) => {
return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
acc[el.id] = true;
return acc;
}, {} as AtomicUnit);
});
targetElements
.filter((el) => !isInGroup(el))
.forEach((el) => {
_atomicUnits.push({
[el.id]: true,
});
});
return _atomicUnits;
};
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements(
[latestElement],
elementsMap,
elements,
scene,
true,
[],
);
} else {
updateBoundElements(latestElement, elementsMap, scene, options);
}
};

View File

@@ -139,7 +139,7 @@ $verticalBreakpoint: 861px;
.ttd-dialog-output-error {
color: red;
font-weight: 700;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;

View File

@@ -1,6 +1,10 @@
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FONT_SIZE,
EDITOR_LS_KEYS,
} from "../../constants";
import { convertToExcalidrawElements, exportToCanvas } from "../../index";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import type { AppClassProperties, BinaryFiles } from "../../types";
@@ -34,7 +38,7 @@ export interface MermaidToExcalidrawLibProps {
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
config?: MermaidConfig,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
@@ -74,10 +78,15 @@ export const convertMermaidToExcalidraw = async ({
let ret;
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
fontSize: DEFAULT_FONT_SIZE,
});
} catch (err: any) {
ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"),
{
fontSize: DEFAULT_FONT_SIZE,
},
);
}
const { elements, files } = ret;

View File

@@ -5,11 +5,10 @@
--avatarList-gap: 0.625rem;
--userList-padding: var(--space-factor);
.UserList__wrapper {
.UserList-wrapper {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
pointer-events: none !important;
}
@@ -22,6 +21,10 @@
align-items: center;
gap: var(--avatarList-gap);
&:empty {
display: none;
}
box-sizing: border-box;
--max-size: calc(
@@ -154,7 +157,66 @@
}
.UserList__collaborators {
position: static;
top: auto;
margin-top: 0;
max-height: 50vh;
overflow-y: auto;
padding: 0.25rem 0.5rem;
border-top: 1px solid var(--userlist-collaborators-border-color);
border-bottom: 1px solid var(--userlist-collaborators-border-color);
&__empty {
color: var(--color-gray-60);
font-size: 0.75rem;
line-height: 150%;
padding: 0.5rem 0;
}
}
.UserList__hint {
padding: 0.5rem 0.75rem;
overflow: hidden;
text-align: center;
color: var(--userlist-hint-text-color);
font-size: 0.75rem;
line-height: 150%;
}
.UserList__search-wrapper {
position: relative;
height: 2.5rem;
svg {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
}
.UserList__search {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
border: 0 !important;
border-radius: 0 !important;
font-size: 0.875rem;
padding-left: 2.5rem !important;
padding-right: 0.75rem !important;
&::placeholder {
color: var(--color-gray-40);
}
&:focus {
box-shadow: none !important;
}
}
}

View File

@@ -9,12 +9,11 @@ import type { ActionManager } from "../actions/manager";
import * as Popover from "@radix-ui/react-popover";
import { Island } from "./Island";
import { QuickSearch } from "./QuickSearch";
import { searchIcon } from "./icons";
import { t } from "../i18n";
import { isShallowEqual } from "../utils";
import { supportsResizeObserver } from "../constants";
import type { MarkRequired } from "../utility-types";
import { ScrollableList } from "./ScrollableList";
export type GoToCollaboratorComponentProps = {
socketId: SocketId;
@@ -41,7 +40,7 @@ const ConditionalTooltipWrapper = ({
shouldWrap ? (
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
) : (
<>{children}</>
<React.Fragment>{children}</React.Fragment>
);
const renderCollaborator = ({
@@ -129,10 +128,6 @@ export const UserList = React.memo(
).filter((collaborator) => collaborator.username?.trim());
const [searchTerm, setSearchTerm] = React.useState("");
const filteredCollaborators = uniqueCollaboratorsArray.filter(
(collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
);
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
@@ -166,6 +161,14 @@ export const UserList = React.memo(
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
const searchTermNormalized = searchTerm.trim().toLowerCase();
const filteredCollaborators = searchTermNormalized
? uniqueCollaboratorsArray.filter((collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
)
: uniqueCollaboratorsArray;
const firstNCollaborators = uniqueCollaboratorsArray.slice(
0,
maxAvatars - 1,
@@ -194,7 +197,7 @@ export const UserList = React.memo(
)}
</div>
) : (
<div className="UserList__wrapper" ref={userListWrapper}>
<div className="UserList-wrapper" ref={userListWrapper}>
<div
className={clsx("UserList", className)}
style={{ [`--max-avatars` as any]: maxAvatars }}
@@ -202,7 +205,13 @@ export const UserList = React.memo(
{firstNAvatarsJSX}
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
<Popover.Root>
<Popover.Root
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
>
<Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
</Popover.Trigger>
@@ -215,43 +224,41 @@ export const UserList = React.memo(
align="end"
sideOffset={10}
>
<Island padding={2}>
<Island style={{ overflow: "hidden" }}>
{uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && (
<QuickSearch
placeholder={t("quickSearch.placeholder")}
onChange={setSearchTerm}
/>
<div className="UserList__search-wrapper">
{searchIcon}
<input
className="UserList__search"
type="text"
placeholder={t("userList.search.placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
</div>
)}
<ScrollableList
className={"dropdown-menu UserList__collaborators"}
placeholder={t("userList.empty")}
>
{/* The list checks for `Children.count()`, hence defensively returning empty list */}
{filteredCollaborators.length > 0
? [
<div className="hint">{t("userList.hint.text")}</div>,
filteredCollaborators.map((collaborator) =>
renderCollaborator({
actionManager,
collaborator,
socketId: collaborator.socketId,
withName: true,
isBeingFollowed:
collaborator.socketId === userToFollow,
}),
),
]
: []}
</ScrollableList>
<Popover.Arrow
width={20}
height={10}
style={{
fill: "var(--popup-bg-color)",
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
}}
/>
<div className="dropdown-menu UserList__collaborators">
{filteredCollaborators.length === 0 && (
<div className="UserList__collaborators__empty">
{t("userList.search.empty")}
</div>
)}
<div className="UserList__hint">
{t("userList.hint.text")}
</div>
{filteredCollaborators.map((collaborator) =>
renderCollaborator({
actionManager,
collaborator,
socketId: collaborator.socketId,
withName: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
)}
</div>
</Island>
</Popover.Content>
</Popover.Root>

View File

@@ -105,7 +105,6 @@ const getRelevantAppStateProps = (
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
});
const areEqual = (

View File

@@ -4,7 +4,7 @@
.dropdown-menu {
position: absolute;
top: 100%;
margin-top: 0.5rem;
margin-top: 0.25rem;
&--mobile {
left: 0;
@@ -35,69 +35,21 @@
.dropdown-menu-item-base {
display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-on-surface);
width: 100%;
box-sizing: border-box;
font-weight: 400;
font-weight: normal;
font-family: inherit;
}
&.manual-hover {
// disable built-in hover due to keyboard navigation
.dropdown-menu-item {
&:hover {
background-color: transparent;
}
&--hovered {
background-color: var(--button-hover-bg) !important;
}
&--selected {
background-color: var(--color-primary-light) !important;
}
}
}
&.fonts {
margin-top: 1rem;
// display max 7 items per list, where each has 2rem (2.25) height and 1px margin top & bottom
// count in 2 groups, where each allocates 1.3*0.75rem font-size and 0.5rem margin bottom, plus one extra 1rem margin top
max-height: calc(7 * (2rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem);
@media screen and (min-width: 1921px) {
max-height: calc(
7 * (2.25rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem
);
}
.dropdown-menu-item-base {
display: inline-flex;
}
.dropdown-menu-group:not(:first-child) {
margin-top: 1rem;
}
.dropdown-menu-group-title {
font-size: 0.75rem;
text-align: left;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.3;
}
}
.dropdown-menu-item {
height: 2rem;
margin: 1px;
padding: 0 0.5rem;
width: calc(100% - 2px);
background-color: transparent;
border: 1px solid transparent;
align-items: center;
height: 2rem;
cursor: pointer;
border-radius: var(--border-radius-md);
@@ -105,6 +57,11 @@
height: 2.25rem;
}
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&__text {
display: flex;
align-items: center;
@@ -126,11 +83,6 @@
}
}
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&:hover {
background-color: var(--button-hover-bg);
text-decoration: none;

View File

@@ -1,62 +1,37 @@
import React, { useEffect, useRef } from "react";
import React from "react";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
import { useExcalidrawAppState } from "../App";
import { THEME } from "../../constants";
import type { ValueOf } from "../../utility-types";
const DropdownMenuItem = ({
icon,
value,
order,
onSelect,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
...rest
}: {
icon?: JSX.Element;
value?: string | number | undefined;
order?: number;
onSelect?: (event: Event) => void;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<button
{...rest}
ref={ref}
value={value}
onClick={handleClick}
className={getDropdownMenuItemClassName(className, selected, hovered)}
type="button"
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</button>
@@ -64,53 +39,24 @@ const DropdownMenuItem = ({
};
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropDownMenuItemBadgeType = {
GREEN: "green",
RED: "red",
BLUE: "blue",
} as const;
export const DropDownMenuItemBadge = ({
type = DropDownMenuItemBadgeType.BLUE,
children,
}: {
type?: ValueOf<typeof DropDownMenuItemBadgeType>;
children: React.ReactNode;
}) => {
const { theme } = useExcalidrawAppState();
const style = {
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
};
switch (type) {
case DropDownMenuItemBadgeType.GREEN:
Object.assign(style, {
backgroundColor: "var(--background-color-badge)",
color: "var(--color-badge)",
});
break;
case DropDownMenuItemBadgeType.RED:
Object.assign(style, {
backgroundColor: "pink",
color: "darkred",
});
break;
case DropDownMenuItemBadgeType.BLUE:
default:
Object.assign(style, {
return (
<div
style={{
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
});
}
return (
<div className="DropDownMenuItemBadge" style={style}>
borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
}}
>
{children}
</div>
);

View File

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

View File

@@ -9,11 +9,9 @@ export const DropdownMenuContentPropsContext = React.createContext<{
export const getDropdownMenuItemClassName = (
className = "",
selected = false,
hovered = false,
) => {
return `dropdown-menu-item dropdown-menu-item-base ${className}
${selected ? "dropdown-menu-item--selected" : ""} ${
hovered ? "dropdown-menu-item--hovered" : ""
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
selected ? "dropdown-menu-item--selected" : ""
}`.trim();
};

View File

@@ -1438,27 +1438,6 @@ export const fontSizeIcon = createIcon(
tablerIconProps,
);
export const FontFamilyHeadingIcon = createIcon(
<>
<g
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 12h10" />
<path d="M7 5v14" />
<path d="M17 5v14" />
<path d="M15 19h4" />
<path d="M15 5h4" />
<path d="M5 19h4" />
<path d="M5 5h4" />
</g>
</>,
tablerIconProps,
);
export const FontFamilyNormalIcon = createIcon(
<>
<g
@@ -2095,35 +2074,6 @@ export const lineEditorIcon = createIcon(
tablerIconProps,
);
// arrow-up-right (modified)
export const sharpArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 18l12 -12" />
<path d="M18 10v-4h-4" />
</g>,
tablerIconProps,
);
// arrow-guide (modified)
export const elbowArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4,19L10,19C11.097,19 12,18.097 12,17L12,9C12,7.903 12.903,7 14,7L21,7" />
<path d="M18 4l3 3l-3 3" />
</g>,
tablerIconProps,
);
// arrow-ramp-right-2 (heavily modified)
export const roundArrowIcon = createIcon(
<g>
<path d="M16,12L20,9L16,6" />
<path d="M6 20c0 -6.075 4.925 -11 11 -11h3" />
</g>,
tablerIconProps,
);
export const collapseDownIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />

View File

@@ -109,7 +109,7 @@ Center.displayName = "Center";
const Logo = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="welcome-screen-center__logo excalifont welcome-screen-decor">
<div className="welcome-screen-center__logo virgil welcome-screen-decor">
{children || <ExcalidrawLogo withText />}
</div>
);
@@ -118,7 +118,7 @@ Logo.displayName = "Logo";
const Heading = ({ children }: { children: React.ReactNode }) => {
return (
<div className="welcome-screen-center__heading welcome-screen-decor excalifont">
<div className="welcome-screen-center__heading welcome-screen-decor virgil">
{children}
</div>
);

View File

@@ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenMenuHintTunnel } = useTunnels();
return (
<WelcomeScreenMenuHintTunnel.In>
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
{WelcomeScreenMenuArrow}
<div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.menuHint")}
@@ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenToolbarHintTunnel } = useTunnels();
return (
<WelcomeScreenToolbarHintTunnel.In>
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.toolbarHint")}
</div>
@@ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenHelpHintTunnel } = useTunnels();
return (
<WelcomeScreenHelpHintTunnel.In>
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div>{children || t("welcomeScreen.defaults.helpHint")}</div>
{WelcomeScreenHelpArrow}
</div>

View File

@@ -1,6 +1,6 @@
.excalidraw {
.excalifont {
font-family: "Excalifont";
.virgil {
font-family: "Virgil";
}
// WelcomeSreen common

View File

@@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss";
import type { AppProps, AppState } from "./types";
import type { AppProps } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@@ -114,24 +114,12 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
};
/**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
*
* Let's think this through and consider:
* - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
* - https://drafts.csswg.org/css-fonts-4/#font-family-prop
* - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
*/
// 1-based in case we ever do `if(element.fontFamily)`
export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
// leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
Excalifont: 5,
Nunito: 6,
"Lilita One": 7,
"Comic Shanns": 8,
"Liberation Sans": 9,
Assistant: 4,
};
export const THEME = {
@@ -159,7 +147,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
@@ -286,7 +274,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
@@ -421,9 +409,3 @@ export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1;
export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
sharp: "sharp",
round: "round",
elbow: "elbow",
};

View File

@@ -152,7 +152,7 @@ body.excalidraw-cursor-resize * {
margin-bottom: 0.25rem;
font-size: 0.75rem;
color: var(--text-primary-color);
font-weight: 400;
font-weight: normal;
display: block;
}
@@ -227,7 +227,14 @@ body.excalidraw-cursor-resize * {
label,
button,
.zIndexButton {
@include outlineButtonIconStyles;
@include outlineButtonStyles;
padding: 0;
svg {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
}
}
@@ -387,7 +394,7 @@ body.excalidraw-cursor-resize * {
.App-menu__left {
overflow-y: auto;
padding: 0.75rem;
width: 200px;
width: 202px;
box-sizing: border-box;
position: absolute;
}
@@ -578,7 +585,7 @@ body.excalidraw-cursor-resize * {
// use custom, minimalistic scrollbar
// (doesn't work in Firefox)
::-webkit-scrollbar {
width: 4px;
width: 3px;
height: 3px;
}
@@ -657,10 +664,6 @@ body.excalidraw-cursor-resize * {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}
.buttonList {
padding: 0.25rem 0;
}
}
.excalidraw__paragraph {
@@ -754,7 +757,7 @@ body.excalidraw-cursor-resize * {
padding: 1rem 1.6rem;
border-radius: 12px;
color: #fff;
font-weight: 700;
font-weight: bold;
letter-spacing: 0.6px;
font-family: "Assistant";
}

View File

@@ -151,9 +151,6 @@
--color-border-outline-variant: #c5c5d0;
--color-surface-primary-container: #e0dfff;
--color-badge: #0b6513;
--background-color-badge: #d3ffd2;
&.theme--dark {
&.theme--dark-background-none {
background: none;

View File

@@ -124,16 +124,6 @@
}
}
@mixin outlineButtonIconStyles {
@include outlineButtonStyles;
padding: 0;
svg {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
}
@mixin avatarStyles {
width: var(--avatar-size, 1.5rem);
height: var(--avatar-size, 1.5rem);
@@ -145,7 +135,7 @@
align-items: center;
cursor: pointer;
font-size: 0.75rem;
font-weight: 700;
font-weight: 800;
line-height: 1;
color: var(--color-gray-90);
flex: 0 0 auto;

View File

@@ -84,11 +84,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": null,
"focus": -0.008153707962747813,
"gap": 1,
},
@@ -119,7 +117,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "id47",
"fixedPoint": null,
"focus": -0.08139534883720931,
"gap": 1,
},
@@ -142,11 +139,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": null,
"focus": 0.10666666666666667,
"gap": 3.834326468444573,
},
@@ -177,7 +172,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "diamond-1",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -245,7 +239,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -291,7 +285,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -334,11 +328,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "text-2",
"fixedPoint": null,
"focus": 0,
"gap": 205,
},
@@ -369,7 +361,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null,
"startBinding": {
"elementId": "text-1",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -395,7 +386,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": "id48",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -438,11 +429,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id40",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -473,7 +462,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null,
"startBinding": {
"elementId": "id39",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -499,7 +487,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"containerId": "id37",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -616,11 +604,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id44",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -651,7 +637,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null,
"startBinding": {
"elementId": "id43",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -677,7 +662,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": "id41",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -723,7 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -769,7 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -839,7 +824,6 @@ exports[`Test Transform > should transform linear elements 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -887,7 +871,6 @@ exports[`Test Transform > should transform linear elements 2`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "triangle",
"endBinding": null,
"fillStyle": "solid",
@@ -1224,7 +1207,7 @@ exports[`Test Transform > should transform text element 1`] = `
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1265,7 +1248,7 @@ exports[`Test Transform > should transform text element 2`] = `
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1480,11 +1463,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "Alice",
"fixedPoint": null,
"focus": 0,
"gap": 5.299874999999986,
},
@@ -1517,7 +1498,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1545,11 +1525,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "B",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1578,7 +1556,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1604,7 +1581,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "B",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [
@@ -1647,7 +1624,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "A",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [
@@ -1690,7 +1667,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Alice",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [
@@ -1733,7 +1710,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [
@@ -1776,7 +1753,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_Alice",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1817,7 +1794,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_B",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -1860,7 +1837,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -1913,7 +1889,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -1966,7 +1941,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -2019,7 +1993,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -2070,7 +2043,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id25",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2111,7 +2084,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id26",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2152,7 +2125,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id27",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2194,7 +2167,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id28",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2458,7 +2431,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id13",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2499,7 +2472,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id14",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2541,7 +2514,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id15",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2585,7 +2558,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id16",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2627,7 +2600,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id17",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@@ -2670,7 +2643,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id18",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontFamily": 1,
"fontSize": 20,
"frameId": null,
"groupIds": [],

View File

@@ -1,11 +1,5 @@
import throttle from "lodash.throttle";
import { ENV } from "../constants";
import type { OrderedExcalidrawElement } from "../element/types";
import {
orderByFractionalIndex,
syncInvalidIndices,
validateFractionalIndices,
} from "../fractionalIndex";
import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex";
import type { AppState } from "../types";
import type { MakeBrand } from "../utility-types";
import { arrayToMap } from "../utils";
@@ -39,37 +33,6 @@ const shouldDiscardRemoteElement = (
return false;
};
const validateIndicesThrottled = throttle(
(
orderedElements: readonly OrderedExcalidrawElement[],
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
) => {
if (
import.meta.env.DEV ||
import.meta.env.MODE === ENV.TEST ||
window?.DEBUG_FRACTIONAL_INDICES
) {
// create new instances due to the mutation
const elements = syncInvalidIndices(
orderedElements.map((x) => ({ ...x })),
);
validateFractionalIndices(elements, {
// throw in dev & test only, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
includeBoundTextValidation: true,
reconciliationContext: {
localElements,
remoteElements,
},
});
}
},
1000 * 60,
{ leading: true, trailing: false },
);
export const reconcileElements = (
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
@@ -109,8 +72,6 @@ export const reconcileElements = (
const orderedElements = orderByFractionalIndex(reconciledElements);
validateIndicesThrottled(orderedElements, localElements, remoteElements);
// de-duplicate indices
syncInvalidIndices(orderedElements);

View File

@@ -1,8 +1,6 @@
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
@@ -23,13 +21,7 @@ import {
isInvisiblySmallElement,
refreshTextDimensions,
} from "../element";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
isTextElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
import { randomId } from "../random";
import {
DEFAULT_FONT_FAMILY,
@@ -46,11 +38,14 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement";
import {
detectLineHeight,
getContainerElement,
getDefaultLineHeight,
} from "../element/textElement";
import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points";
import { getLineHeight } from "../fonts";
type RestoredAppState = Omit<
AppState,
@@ -94,21 +89,11 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (
element: ExcalidrawLinearElement,
binding: PointBinding | null,
): PointBinding | null => {
const repairBinding = (binding: PointBinding | null) => {
if (!binding) {
return null;
}
return {
...binding,
focus: binding.focus || 0,
fixedPoint: isElbowArrow(element)
? binding.fixedPoint ?? ([0, 0] as [number, number])
: null,
};
return { ...binding, focus: binding.focus || 0 };
};
const restoreElementWithProperties = <
@@ -215,7 +200,7 @@ const restoreElement = (
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getLineHeight(element.fontFamily));
getDefaultLineHeight(element.fontFamily));
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
@@ -254,7 +239,11 @@ const restoreElement = (
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
case "draw":
const { startArrowhead = null, endArrowhead = null } = element;
case "arrow": {
const {
startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
@@ -274,8 +263,8 @@ const restoreElement = (
(element.type as ExcalidrawElementType | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
startBinding: repairBinding(element.startBinding),
endBinding: repairBinding(element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
@@ -284,36 +273,6 @@ const restoreElement = (
y,
...getSizeFromPoints(points),
});
case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
type: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
points,
x,
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
});
}
// generic elements
@@ -501,23 +460,6 @@ export const restoreElements = (
),
);
}
if (isLinearElement(element)) {
if (
element.startBinding &&
(!restoredElementsMap.has(element.startBinding.elementId) ||
!isArrowElement(element))
) {
(element as Mutable<ExcalidrawLinearElement>).startBinding = null;
}
if (
element.endBinding &&
(!restoredElementsMap.has(element.endBinding.elementId) ||
!isArrowElement(element))
) {
(element as Mutable<ExcalidrawLinearElement>).endBinding = null;
}
}
}
return restoredElements;

View File

@@ -771,7 +771,6 @@ describe("Test Transform", () => {
const [arrow, rect] = excalidrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
fixedPoint: null,
focus: 0,
gap: 205,
});

View File

@@ -13,13 +13,16 @@ import {
import { bindLinearElement } from "../element/binding";
import type { ElementConstructorOpts } from "../element/newElement";
import {
newArrowElement,
newFrameElement,
newImageElement,
newMagicFrameElement,
newTextElement,
} from "../element/newElement";
import { measureText, normalizeText } from "../element/textElement";
import {
getDefaultLineHeight,
measureText,
normalizeText,
} from "../element/textElement";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -51,8 +54,6 @@ import {
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -547,7 +548,7 @@ export const convertToExcalidrawElements = (
case "arrow": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newArrowElement({
excalidrawElement = newLinearElement({
width,
height,
endArrowhead: "arrow",
@@ -556,7 +557,6 @@ export const convertToExcalidrawElements = (
[width, height],
],
...element,
type: "arrow",
});
Object.assign(
@@ -568,7 +568,8 @@ export const convertToExcalidrawElements = (
case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
const lineHeight =
element?.lineHeight || getDefaultLineHeight(fontFamily);
const text = element.text ?? "";
const normalizedText = normalizeText(text);
const metrics = measureText(
@@ -658,7 +659,7 @@ export const convertToExcalidrawElements = (
elementStore.add(container);
elementStore.add(text);
if (isArrowElement(container)) {
if (container.type === "arrow") {
const originalStart =
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
@@ -677,7 +678,7 @@ export const convertToExcalidrawElements = (
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container,
container as ExcalidrawArrowElement,
originalStart,
originalEnd,
elementStore,

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