Compare commits

...

23 Commits

Author SHA1 Message Date
Marcel Mraz e4c3744dc4 Normalize indices on init 2024-07-29 23:38:44 +02:00
Ryan Di 7b36de0476 fix: linear elements not selected on pointer up from hitting its bound text (#8285)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-27 13:02:00 +00:00
David Luzar 2427e622b0 feat: improve mermaid detection on paste (#8287) 2024-07-27 12:36:54 +02:00
Marcel Mraz 62228e0bbb feat: introduce font picker (#8012)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-25 18:55:55 +02:00
DDDDD12138 4c5408263c chore: Correct Typos in Code Comments (#8268)
chore: correct typos

Co-authored-by: wuzhiqing <wuzhiqing@linklogis.com>
2024-07-23 14:26:55 +05:30
Aakansha Doshi bd7b778f41 perf: cache the temp canvas created for labeled arrows (#8267)
* perf: cache the temp canvas created for labeled arrows

* use allEleemntsMap so bound text element can be retrieved when editing

* remove logs

* fix rotation

* pass isRotating

* feat: cache `element.angle` instead of relying on `appState.isRotating`

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-07-23 11:17:32 +05:30
David Luzar 43b2476dfe fix: revert default element canvas padding change (#8266) 2024-07-22 11:47:16 +02:00
BlueGreenMagick df8875a497 fix: freedraw jittering (#8238) 2024-07-14 08:44:47 +00:00
David Luzar 6fbc44fd1f fix: messed up env variable (#8231) 2024-07-11 14:33:35 +02:00
Aakansha Doshi d25a7d365b feat: upgrade mermaid-to-excalidraw to v1.1.0 (#8226)
* feat: upgrade mermaid-to-excalidraw to v1.1.0

* fixes

* upgrade and remove config as its redundant

* lint

* upgrade to v1.1.0
2024-07-10 20:57:43 +05:30
David Luzar e52c2cd0b6 fix: log allowed events (#8224) 2024-07-09 12:16:14 +02:00
David Luzar 96eeec5119 feat: bump max file size (#8220) 2024-07-08 18:35:13 +02:00
Hamir Mahal f5221d521b ci: upgrade gh actions checkout and setup-node to v4 (#8168)
fix: usage of `node12 which is deprecated`
2024-07-08 14:26:25 +05:30
Alexandre Lemoine db2c235cd4 Fix : exportToCanvas() doc example (#8127) 2024-07-08 08:52:05 +00:00
David Luzar 148b895f46 feat: smarter preferred lang detection (#8205) 2024-07-04 17:55:35 +02:00
DDDDD12138 d9258a736b chore: Consolidate i18n import in LanguageList component (#8201) 2024-07-04 17:34:16 +02:00
zsviczian 2e1f08c796 fix: memory leak - scene.destroy() and window.launchQueue (#8198) 2024-07-02 22:08:02 +02:00
David Luzar 1d5b41dabb fix: stop updating text versions on init (#8191) 2024-07-01 14:04:58 +02:00
Márk Tolmács 66a2f24296 fix: Add binding update to manual stat changes (#8183)
Manual stats changes now respect previous element bindings.
2024-07-01 09:45:31 +02:00
Márk Tolmács 04668d8263 fix: Binding after duplicating is now applied for both the old and duplicate shapes (#8185)
Using ALT/OPT + drag to clone does not transfer the bindings (or leaves the duplicates in place of the old one , which are also not bound).

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-06-28 15:28:48 +02:00
David Luzar abbeed3d5f feat: support Stats bound text fontSize editing (#8187) 2024-06-28 13:52:29 +02:00
Márk Tolmács ba8c09d529 fix: Incorrect point offsetting in LinearElementEditor.movePoints() (#8145)
The LinearElementEditor.movePoints() function incorrectly calculates the offset for local linear element points when multiple targetPoints are provided, one of those target points is index === 0 AND the other points are moved in the negative direction, and ending up with negative local coordinates.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-06-28 12:23:10 +02:00
David Luzar 744b3e5d09 fix: stats state leaking & race conds (#8177) 2024-06-26 23:31:08 +02:00
160 changed files with 5182 additions and 2275 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD= VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_DISABLE_TRACKING=true VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false FAST_REFRESH=false
+1 -1
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_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_DISABLE_TRACKING= VITE_APP_ENABLE_TRACKING=false
+2 -2
View File
@@ -9,9 +9,9 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Setup Node.js 18.x - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 18.x
- name: Install and test - name: Install and test
@@ -90,7 +90,7 @@ function App() {
<img src={canvasUrl} alt="" /> <img src={canvasUrl} alt="" />
</div> </div>
<div style={{ height: "400px" }}> <div style={{ height: "400px" }}>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} <Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
/> />
</div> </div>
</> </>
+1 -1
View File
@@ -59,7 +59,7 @@ pre a {
padding: 5px; padding: 5px;
background: #70b1ec; background: #70b1ec;
color: white; color: white;
font-weight: bold; font-weight: 700;
border: none; border: none;
} }
+2 -2
View File
@@ -872,7 +872,7 @@ export default function App({
files: excalidrawAPI.getFiles(), files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil"; ctx.font = "30px Excalifont";
ctx.strokeText("My custom text", 50, 60); ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL()); setCanvasUrl(canvas.toDataURL());
}} }}
@@ -893,7 +893,7 @@ export default function App({
files: excalidrawAPI.getFiles(), files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil"; ctx.font = "30px Excalifont";
ctx.strokeText("My custom text", 50, 60); ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL()); setCanvasUrl(canvas.toDataURL());
}} }}
+1 -1
View File
@@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
]; ];
export default { export default {
elements, elements,
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 }, appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
scrollToContent: true, scrollToContent: true,
libraryItems: [ libraryItems: [
[ [
@@ -34,3 +34,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# copied assets
public/*.woff2
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"dev": "yarn build:workspace && next dev -p 3005", "dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build", "build": "yarn build:workspace && next build",
"start": "next start -p 3006", "start": "next start -p 3006",
@@ -1,4 +1,5 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss"; import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -15,7 +16,9 @@ export default function Page() {
<> <>
<a href="/excalidraw-in-pages">Switch to Pages router</a> <a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1> <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 */} {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly /> <ExcalidrawWithClientOnly />
</> </>
@@ -7,7 +7,7 @@ a {
color: #1c7ed6; color: #1c7ed6;
font-size: 20px; font-size: 20px;
text-decoration: none; text-decoration: none;
font-weight: 550; font-weight: 500;
} }
.page-title { .page-title {
@@ -0,0 +1,2 @@
# copied assets
public/*.woff2
@@ -11,6 +11,7 @@
<title>React App</title> <title>React App</title>
<script> <script>
window.name = "codesandbox"; window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<link rel="stylesheet" href="/dist/browser/dev/index.css" /> <link rel="stylesheet" href="/dist/browser/dev/index.css" />
</head> </head>
@@ -12,8 +12,10 @@
"typescript": "^5" "typescript": "^5"
}, },
"scripts": { "scripts": {
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", "copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002" "build:preview": "yarn build && vite preview --port 5002"
} }
} }
+16 -25
View File
@@ -1,5 +1,4 @@
import polyfill from "../packages/excalidraw/polyfill"; import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics"; import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState"; import { getDefaultAppState } from "../packages/excalidraw/appState";
@@ -22,12 +21,12 @@ import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRef
import { t } from "../packages/excalidraw/i18n"; import { t } from "../packages/excalidraw/i18n";
import { import {
Excalidraw, Excalidraw,
defaultLang,
LiveCollaborationTrigger, LiveCollaborationTrigger,
TTDDialog, TTDDialog,
TTDDialogTrigger, TTDDialogTrigger,
StoreAction, StoreAction,
reconcileElements, reconcileElements,
normalizeIndices,
} from "../packages/excalidraw"; } from "../packages/excalidraw";
import type { import type {
AppState, AppState,
@@ -93,7 +92,7 @@ import {
import { AppMainMenu } from "./components/AppMainMenu"; import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter"; import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai"; import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai"; import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai"; import { appJotaiStore } from "./app-jotai";
@@ -121,6 +120,8 @@ import {
youtubeIcon, youtubeIcon,
} from "../packages/excalidraw/components/icons"; } from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
polyfill(); polyfill();
@@ -172,11 +173,6 @@ if (window.self !== window.top) {
} }
} }
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = { const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"), title: t("overwriteConfirm.modal.shareableLink.title"),
description: ( description: (
@@ -310,31 +306,34 @@ const initializeScene = async (opts: {
key: roomLinkData.roomKey, key: roomLinkData.roomKey,
}; };
} else if (scene) { } else if (scene) {
const normalizedScene = {
...scene,
// non-collab scenes are always always normalized on init
// collab scenes are normalized only on "first-in-room" as part of collabAPI
elements: normalizeIndices(scene.elements),
};
return isExternalScene && jsonBackendMatch return isExternalScene && jsonBackendMatch
? { ? {
scene, scene: normalizedScene,
isExternalScene, isExternalScene,
id: jsonBackendMatch[1], id: jsonBackendMatch[1],
key: jsonBackendMatch[2], key: jsonBackendMatch[2],
} }
: { scene, isExternalScene: false }; : { scene: normalizedScene, isExternalScene: false };
} }
return { scene: null, isExternalScene: false }; return { scene: null, isExternalScene: false };
}; };
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom); const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme(); const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -490,11 +489,7 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
let langCode = languageDetector.detect() || defaultLang.code; setLangCode(getPreferredLanguage());
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...localDataState, ...localDataState,
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
@@ -595,10 +590,6 @@ const ExcalidrawWrapper = () => {
}; };
}, [excalidrawAPI]); }, [excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const onChange = ( const onChange = (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
appState: AppState, appState: AppState,
@@ -1,8 +1,7 @@
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { appLangCodeAtom } from "../App"; import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { useI18n } from "../../packages/excalidraw/i18n"; import { appLangCodeAtom } from "./language-state";
import { languages } from "../../packages/excalidraw/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n(); const { t, langCode } = useI18n();
@@ -0,0 +1,25 @@
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;
};
@@ -0,0 +1,15 @@
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;
};
+11 -1
View File
@@ -18,6 +18,7 @@ import {
restoreElements, restoreElements,
zoomToFitBounds, zoomToFitBounds,
reconcileElements, reconcileElements,
normalizeIndices,
} from "../../packages/excalidraw"; } from "../../packages/excalidraw";
import type { Collaborator, Gesture } from "../../packages/excalidraw/types"; import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
import { import {
@@ -637,7 +638,16 @@ class Collab extends PureComponent<CollabProps, CollabState> {
fetchScene: true, fetchScene: true,
roomLinkData: existingRoomLinkData, roomLinkData: existingRoomLinkData,
}); });
scenePromise.resolve(sceneData);
if (sceneData) {
scenePromise.resolve({
...sceneData,
// normalize fractional indices on init for shared scenes, while making sure there are no other collaborators
elements: normalizeIndices([...sceneData.elements]),
});
} else {
scenePromise.resolve(null);
}
}); });
this.portal.socket.on( this.portal.socket.on(
+1 -1
View File
@@ -6,7 +6,7 @@ import {
import type { Theme } from "../../packages/excalidraw/element/types"; import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index"; import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "./LanguageList"; import { LanguageList } from "../app-language/LanguageList";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any; onCollabDialogOpen: () => any;
+1 -1
View File
@@ -254,7 +254,7 @@ export const loadScene = async (
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements, localDataState?.elements,
{ repairBindings: true, refreshDimensions: false }, { repairBindings: true },
); );
} else { } else {
data = restore(localDataState || null, null, null, { data = restore(localDataState || null, null, null, {
+63 -4
View File
@@ -114,6 +114,14 @@
) { ) {
window.location.href = "https://app.excalidraw.com"; window.location.href = "https://app.excalidraw.com";
} }
// point into our CDN in prod
window.EXCALIDRAW_ASSET_PATH =
"https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/";
</script>
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<% } %> <% } %>
@@ -124,22 +132,74 @@
<!-- Excalidraw version --> <!-- Excalidraw version -->
<meta name="version" content="{version}" /> <meta name="version" content="{version}" />
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Preload all default fonts and Virgil for backwards compatibility to avoid swap on init -->
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<link <link
rel="preload" rel="preload"
href="/Virgil.woff2" href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Excalifont-Regular-C9eKQy_N.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin="anonymous" crossorigin="anonymous"
/> />
<link <link
rel="preload" rel="preload"
href="/Cascadia.woff2" href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Virgil-Regular-hO16qHwV.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/ComicShanns-Regular-D0c8wzsC.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } else { %>
<!-- 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 enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin="anonymous" crossorigin="anonymous"
/> />
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" /> <!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script> <script>
@@ -158,7 +218,6 @@
</script> </script>
<% } %> <% } %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
+4 -3
View File
@@ -31,12 +31,13 @@
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "build:preview": "yarn build && vite preview --port 5000"
} }
} }
@@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center" class="welcome-screen-center"
> >
<div <div
class="welcome-screen-center__logo virgil welcome-screen-decor" class="welcome-screen-center__logo excalifont welcome-screen-decor"
> >
<div <div
class="ExcalidrawLogo is-small" class="ExcalidrawLogo is-small"
@@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div> </div>
</div> </div>
<div <div
class="welcome-screen-center__heading welcome-screen-decor virgil" class="welcome-screen-center__heading welcome-screen-decor excalifont"
> >
All your data is saved locally in your browser. All your data is saved locally in your browser.
</div> </div>
+11 -1
View File
@@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker"; import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables // To load .env.local variables
const envVars = loadEnv("", `../`); const envVars = loadEnv("", `../`);
@@ -22,6 +23,14 @@ export default defineConfig({
outDir: "build", outDir: "build",
rollupOptions: { rollupOptions: {
output: { 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 // Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with // can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load // app precache. en.json and percentages.json are needed for first load
@@ -35,12 +44,13 @@ export default defineConfig({
// Taking the substring after "locales/" // Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`; return `locales/${id.substring(index + 8)}`;
} }
}, }
}, },
}, },
sourcemap: true, sourcemap: true,
}, },
plugins: [ plugins: [
woff2BrowserPlugin(),
react(), react(),
checker({ checker({
typescript: true, typescript: true,
+2
View File
@@ -19,6 +19,8 @@ Please add the latest change on the top under the correct section.
- 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 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) - `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) - 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)
@@ -131,7 +131,12 @@ export const actionFinalize = register({
-1, -1,
arrayToMap(elements), arrayToMap(elements),
); );
maybeBindLinearElement(multiPointElement, appState, { x, y }, app); maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
);
} }
} }
+1 -1
View File
@@ -124,7 +124,7 @@ const flipElements = (
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
app, elementsMap,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
); );
@@ -155,13 +155,15 @@ describe("element locking", () => {
}); });
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
fontFamily: FONT_FAMILY.Cascadia, fontFamily: FONT_FAMILY["Comic Shanns"],
}); });
h.elements = [rect, text]; h.elements = [rect, text];
API.setSelectedElements([rect, text]); API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked(); expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked(); expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
}); });
}); });
}); });
+368 -83
View File
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
import type { StoreActionType } from "../store";
import { import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
// TODO barnabasmolnar/editor-redesign // TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons // ArrowHead icons
@@ -38,9 +41,6 @@ import {
FontSizeExtraLargeIcon, FontSizeExtraLargeIcon,
EdgeSharpIcon, EdgeSharpIcon,
EdgeRoundIcon, EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
@@ -65,10 +65,7 @@ import {
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element"; } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { import { getBoundTextElement } from "../element/textElement";
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { import {
isBoundToContainer, isBoundToContainer,
isLinearElement, isLinearElement,
@@ -94,9 +91,10 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { Fonts, getLineHeight } from "../fonts";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -729,104 +727,391 @@ 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({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
label: "labels.fontFamily", label: "labels.fontFamily",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return { const { cachedElements, resetAll, resetContainers, ...nextAppState } =
elements: changeProperty( value as ChangeFontFamilyData;
if (resetAll) {
const nextElements = changeProperty(
elements, elements,
appState, appState,
(oldElement) => { (element) => {
if (isTextElement(oldElement)) { const cachedElement = cachedElements?.get(element.id);
const newElement: ExcalidrawTextElement = newElementWith( if (cachedElement) {
oldElement, const newElement = newElementWith(element, {
{ ...cachedElement,
fontFamily: value, });
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
return newElement; return newElement;
} }
return oldElement; return element;
}, },
true, 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: {
...appState, ...appState,
currentItemFontFamily: value, ...nextAppState,
}, },
storeAction: StoreAction.CAPTURE, storeAction: nexStoreAction,
}; };
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, updateData, app }) => { PanelComponent: ({ elements, appState, app, updateData }) => {
const options: { const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
value: FontFamilyValues; const prevSelectedFontFamilyRef = useRef<number | null>(null);
text: string; // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
icon: JSX.Element; const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
testId: string; const isUnmounted = useRef(true);
}[] = [
{ const selectedFontFamily = useMemo(() => {
value: FONT_FAMILY.Virgil, const getFontFamily = (
text: t("labels.handDrawn"), elementsArray: readonly ExcalidrawElement[],
icon: FreedrawIcon, elementsMap: Map<string, ExcalidrawElement>,
testId: "font-family-virgil", ) =>
}, getFormValue(
{ elementsArray,
value: FONT_FAMILY.Helvetica, appState,
text: t("labels.normal"), (element) => {
icon: FontFamilyNormalIcon, if (isTextElement(element)) {
testId: "font-family-normal", return element.fontFamily;
}, }
{ const boundTextElement = getBoundTextElement(element, elementsMap);
value: FONT_FAMILY.Cascadia, if (boundTextElement) {
text: t("labels.code"), return boundTextElement.fontFamily;
icon: FontFamilyCodeIcon, }
testId: "font-family-code", 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;
};
}, []);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.fontFamily")}</legend> <legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false> <FontPicker
group="font-family" isOpened={appState.openPopup === "fontFamily"}
options={options} selectedFontFamily={selectedFontFamily}
value={getFormValue( hoveredFontFamily={appState.currentHoveredFontFamily}
elements, onSelect={(fontFamily) => {
appState, setBatchedData({
(element) => { openPopup: null,
if (isTextElement(element)) { currentHoveredFontFamily: null,
return element.fontFamily; 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),
);
}
} }
const boundTextElement = getBoundTextElement(
element, setBatchedData({
app.scene.getNonDeletedElementsMap(), openPopup: "fontFamily",
); });
if (boundTextElement) { } else {
return boundTextElement.fontFamily; // 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);
} }
return null;
}, cachedElementsRef.current.clear();
(element) => }
isTextElement(element) || }}
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/> />
</fieldset> </fieldset>
); );
+3 -5
View File
@@ -12,10 +12,7 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { import { getBoundTextElement } from "../element/textElement";
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
@@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene";
import type { ExcalidrawTextElement } from "../element/types"; import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons"; import { paintIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { getLineHeight } from "../fonts";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
@@ -122,7 +120,7 @@ export const actionPasteStyles = register({
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
lineHeight: lineHeight:
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight || (elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getDefaultLineHeight(fontFamily), getLineHeight(fontFamily),
}); });
let container = null; let container = null;
if (newElement.containerId) { if (newElement.containerId) {
+10 -7
View File
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a // place here categories that you want to track. We want to track just a
// small subset of categories at a given time. // small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[]; const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
export const trackEvent = ( export const trackEvent = (
category: string, category: string,
@@ -9,17 +9,20 @@ export const trackEvent = (
value?: number, value?: number,
) => { ) => {
try { try {
// prettier-ignore
if ( if (
typeof window === "undefined" typeof window === "undefined" ||
|| import.meta.env.VITE_WORKER_ID import.meta.env.VITE_WORKER_ID ||
// comment out to debug locally import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|| import.meta.env.PROD
) { ) {
return; return;
} }
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) { if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
return;
}
if (import.meta.env.DEV) {
// comment out to debug in dev
return; return;
} }
+2
View File
@@ -36,6 +36,7 @@ export const getDefaultAppState = (): Omit<
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up", cursorButton: "up",
activeEmbeddable: null, activeEmbeddable: null,
draggingElement: null, draggingElement: null,
@@ -149,6 +150,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { 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 }, cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false }, draggingElement: { browser: false, export: false, server: false },
+1 -3
View File
@@ -158,10 +158,8 @@ export const SelectedShapeActions = ({
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && ( targetElements.some(isTextElement)) && (
<> <>
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")} {renderAction("changeFontFamily")}
{renderAction("changeFontSize")}
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) && suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")} renderAction("changeTextAlign")}
+87 -127
View File
@@ -49,7 +49,6 @@ import {
import type { PastedMixedContent } from "../clipboard"; import type { PastedMixedContent } from "../clipboard";
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
import type { EXPORT_IMAGE_TYPES } from "../constants"; import type { EXPORT_IMAGE_TYPES } from "../constants";
import { DEFAULT_FONT_SIZE } from "../constants";
import { import {
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
@@ -225,16 +224,8 @@ import type {
ScrollBars, ScrollBars,
} from "../scene/types"; } from "../scene/types";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes"; import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes";
import type { GeometricShape } from "../../utils/geometry/shape"; import { getSelectionBoxShape } from "../../utils/geometry/shape";
import {
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
getSelectionBoxShape,
} from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision"; import { isPointInShape } from "../../utils/collision";
import type { import type {
AppClassProperties, AppClassProperties,
@@ -329,7 +320,6 @@ import {
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerElement, getContainerElement,
getDefaultLineHeight,
getLineHeightInPx, getLineHeightInPx,
getMinTextElementWidth, getMinTextElementWidth,
isMeasureTextSupported, isMeasureTextSupported,
@@ -345,7 +335,7 @@ import {
import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock"; import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts"; import { Fonts, getLineHeight } from "../fonts";
import { import {
getFrameChildren, getFrameChildren,
isCursorInFrame, isCursorInFrame,
@@ -424,7 +414,6 @@ import {
hitElementBoundText, hitElementBoundText,
hitElementBoundingBoxOnly, hitElementBoundingBoxOnly,
hitElementItself, hitElementItself,
shouldTestInside,
} from "../element/collision"; } from "../element/collision";
import { textWysiwyg } from "../element/textWysiwyg"; import { textWysiwyg } from "../element/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars"; import { isOverScrollBars } from "../scene/scrollbars";
@@ -541,8 +530,8 @@ class App extends React.Component<AppProps, AppState> {
private excalidrawContainerRef = React.createRef<HTMLDivElement>(); private excalidrawContainerRef = React.createRef<HTMLDivElement>();
public scene: Scene; public scene: Scene;
public fonts: Fonts;
public renderer: Renderer; public renderer: Renderer;
private fonts: Fonts;
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
@@ -2344,11 +2333,6 @@ 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.resetStore();
this.resetHistory(); this.resetHistory();
@@ -2356,6 +2340,12 @@ class App extends React.Component<AppProps, AppState> {
...scene, ...scene,
storeAction: StoreAction.UPDATE, 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.load();
}; };
private isMobileBreakpoint = (width: number, height: number) => { private isMobileBreakpoint = (width: number, height: number) => {
@@ -2448,6 +2438,10 @@ class App extends React.Component<AppProps, AppState> {
configurable: true, configurable: true,
value: this.store, value: this.store,
}, },
fonts: {
configurable: true,
value: this.fonts,
},
}); });
} }
@@ -2498,7 +2492,9 @@ class App extends React.Component<AppProps, AppState> {
} }
public componentWillUnmount() { public componentWillUnmount() {
(window as any).launchQueue?.setConsumer(() => {});
this.renderer.destroy(); this.renderer.destroy();
this.scene.destroy();
this.scene = new Scene(); this.scene = new Scene();
this.fonts = new Fonts({ scene: this.scene }); this.fonts = new Fonts({ scene: this.scene });
this.renderer = new Renderer(this.scene); this.renderer = new Renderer(this.scene);
@@ -2507,7 +2503,6 @@ class App extends React.Component<AppProps, AppState> {
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
this.unmounted = true; this.unmounted = true;
this.removeEventListeners(); this.removeEventListeners();
this.scene.destroy();
this.library.destroy(); this.library.destroy();
this.laserTrails.stop(); this.laserTrails.stop();
this.eraserTrail.stop(); this.eraserTrail.stop();
@@ -2584,7 +2579,7 @@ class App extends React.Component<AppProps, AppState> {
// rerender text elements on font load to fix #637 && #1553 // rerender text elements on font load to fix #637 && #1553
addEventListener(document.fonts, "loadingdone", (event) => { addEventListener(document.fonts, "loadingdone", (event) => {
const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
this.fonts.onFontsLoaded(loadedFontFaces); this.fonts.onLoaded(loadedFontFaces);
}), }),
// Safari-only desktop pinch zoom // Safari-only desktop pinch zoom
addEventListener( addEventListener(
@@ -2819,7 +2814,7 @@ class App extends React.Component<AppProps, AppState> {
nonDeletedElementsMap, nonDeletedElementsMap,
), ),
), ),
this, this.scene.getNonDeletedElementsMap(),
); );
} }
@@ -3062,9 +3057,7 @@ class App extends React.Component<AppProps, AppState> {
try { try {
const { elements: skeletonElements, files } = const { elements: skeletonElements, files } =
await api.parseMermaidToExcalidraw(data.text, { await api.parseMermaidToExcalidraw(data.text);
fontSize: DEFAULT_FONT_SIZE,
});
const elements = convertToExcalidrawElements(skeletonElements, { const elements = convertToExcalidrawElements(skeletonElements, {
regenerateIds: true, regenerateIds: true,
@@ -3389,7 +3382,7 @@ class App extends React.Component<AppProps, AppState> {
fontSize: textElementProps.fontSize, fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily, fontFamily: textElementProps.fontFamily,
}); });
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); const lineHeight = getLineHeight(textElementProps.fontFamily);
const [x1, , x2] = getVisibleSceneBounds(this.state); const [x1, , x2] = getVisibleSceneBounds(this.state);
// long texts should not go beyond 800 pixels in width nor should it go below 200 px // 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); const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
@@ -3407,13 +3400,13 @@ class App extends React.Component<AppProps, AppState> {
}); });
let metrics = measureText(originalText, fontString, lineHeight); let metrics = measureText(originalText, fontString, lineHeight);
const isTextWrapped = metrics.width > maxTextWidth; const isTextUnwrapped = metrics.width > maxTextWidth;
const text = isTextWrapped const text = isTextUnwrapped
? wrapText(originalText, fontString, maxTextWidth) ? wrapText(originalText, fontString, maxTextWidth)
: originalText; : originalText;
metrics = isTextWrapped metrics = isTextUnwrapped
? measureText(text, fontString, lineHeight) ? measureText(text, fontString, lineHeight)
: metrics; : metrics;
@@ -3427,7 +3420,7 @@ class App extends React.Component<AppProps, AppState> {
text, text,
originalText, originalText,
lineHeight, lineHeight,
autoResize: !isTextWrapped, autoResize: !isTextUnwrapped,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
acc.push(element); acc.push(element);
@@ -4008,7 +4001,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
suggestedBindings: getSuggestedBindingsForArrows( suggestedBindings: getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this, this.scene.getNonDeletedElementsMap(),
), ),
}); });
@@ -4117,6 +4110,36 @@ 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 (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") { if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: "selection" });
@@ -4179,7 +4202,7 @@ class App extends React.Component<AppProps, AppState> {
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
this.scene.getSelectedElements(this.state).filter(isLinearElement), this.scene.getSelectedElements(this.state).filter(isLinearElement),
this, this.scene.getNonDeletedElementsMap(),
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
); );
@@ -4491,84 +4514,6 @@ class App extends React.Component<AppProps, AppState> {
return null; 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( private getElementAtPosition(
x: number, x: number,
y: number, y: number,
@@ -4602,7 +4547,10 @@ class App extends React.Component<AppProps, AppState> {
x, x,
y, y,
element: elementWithHighestZIndex, element: elementWithHighestZIndex,
shape: this.getElementShape(elementWithHighestZIndex), shape: getElementShape(
elementWithHighestZIndex,
this.scene.getNonDeletedElementsMap(),
),
// when overlapping, we would like to be more precise // when overlapping, we would like to be more precise
// this also avoids the need to update past tests // this also avoids the need to update past tests
threshold: this.getElementHitThreshold() / 2, threshold: this.getElementHitThreshold() / 2,
@@ -4697,7 +4645,7 @@ class App extends React.Component<AppProps, AppState> {
const hitBoundTextOfElement = hitElementBoundText( const hitBoundTextOfElement = hitElementBoundText(
x, x,
y, y,
this.getBoundTextShape(element), getBoundTextShape(element, this.scene.getNonDeletedElementsMap()),
); );
if (hitBoundTextOfElement) { if (hitBoundTextOfElement) {
return true; return true;
@@ -4707,7 +4655,7 @@ class App extends React.Component<AppProps, AppState> {
x, x,
y, y,
element, element,
shape: this.getElementShape(element), shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(element) frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element) ? this.frameNameBoundsCache.get(element)
@@ -4739,7 +4687,10 @@ class App extends React.Component<AppProps, AppState> {
x, x,
y, y,
element: elements[index], element: elements[index],
shape: this.getElementShape(elements[index]), shape: getElementShape(
elements[index],
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
}) })
) { ) {
@@ -4812,7 +4763,7 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement?.fontFamily || this.state.currentItemFontFamily; existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight = const lineHeight =
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily); existingTextElement?.lineHeight || getLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize; const fontSize = this.state.currentItemFontSize;
if ( if (
@@ -4997,7 +4948,10 @@ class App extends React.Component<AppProps, AppState> {
x: sceneX, x: sceneX,
y: sceneY, y: sceneY,
element: container, element: container,
shape: this.getElementShape(container), shape: getElementShape(
container,
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
}) })
) { ) {
@@ -5689,7 +5643,10 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX, x: scenePointerX,
y: scenePointerY, y: scenePointerY,
element, element,
shape: this.getElementShape(element), shape: getElementShape(
element,
this.scene.getNonDeletedElementsMap(),
),
}) })
) { ) {
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
@@ -6808,7 +6765,7 @@ class App extends React.Component<AppProps, AppState> {
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
pointerDownState.origin, pointerDownState.origin,
this, this.scene.getNonDeletedElementsMap(),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
this.setState({ this.setState({
@@ -7070,7 +7027,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
pointerDownState.origin, pointerDownState.origin,
this, this.scene.getNonDeletedElementsMap(),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
@@ -7540,7 +7497,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
suggestedBindings: getSuggestedBindingsForArrows( suggestedBindings: getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this, this.scene.getNonDeletedElementsMap(),
), ),
}); });
@@ -8061,7 +8018,7 @@ class App extends React.Component<AppProps, AppState> {
draggingElement, draggingElement,
this.state, this.state,
pointerCoords, pointerCoords,
this, this.scene.getNonDeletedElementsMap(),
); );
} }
this.setState({ suggestedBindings: [], startBoundElement: null }); this.setState({ suggestedBindings: [], startBoundElement: null });
@@ -8551,7 +8508,10 @@ class App extends React.Component<AppProps, AppState> {
x: pointerDownState.origin.x, x: pointerDownState.origin.x,
y: pointerDownState.origin.y, y: pointerDownState.origin.y,
element: hitElement, element: hitElement,
shape: this.getElementShape(hitElement), shape: getElementShape(
hitElement,
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(hitElement) frameNameBound: isFrameLikeElement(hitElement)
? this.frameNameBoundsCache.get(hitElement) ? this.frameNameBoundsCache.get(hitElement)
@@ -8619,7 +8579,7 @@ class App extends React.Component<AppProps, AppState> {
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
linearElements, linearElements,
this, this.scene.getNonDeletedElementsMap(),
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
); );
@@ -9107,7 +9067,7 @@ class App extends React.Component<AppProps, AppState> {
}): void => { }): void => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords, pointerCoords,
this, this.scene.getNonDeletedElementsMap(),
); );
this.setState({ this.setState({
suggestedBindings: suggestedBindings:
@@ -9134,7 +9094,7 @@ class App extends React.Component<AppProps, AppState> {
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => { (acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
coords, coords,
this, this.scene.getNonDeletedElementsMap(),
); );
if ( if (
hoveredBindableElement != null && hoveredBindableElement != null &&
@@ -9666,7 +9626,7 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
const suggestedBindings = getSuggestedBindingsForArrows( const suggestedBindings = getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this, this.scene.getNonDeletedElementsMap(),
); );
const elementsToHighlight = new Set<ExcalidrawElement>(); const elementsToHighlight = new Set<ExcalidrawElement>();
@@ -0,0 +1,12 @@
@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;
}
}
}
@@ -0,0 +1,36 @@
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>
);
},
);
@@ -1,4 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { ButtonIcon } from "./ButtonIcon";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>( export const ButtonIconSelect = <T extends Object>(
@@ -24,21 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
} }
), ),
) => ( ) => (
<div className="buttonList buttonListIcon"> <div className="buttonList">
{props.options.map((option) => {props.options.map((option) =>
props.type === "button" ? ( props.type === "button" ? (
<button <ButtonIcon
type="button"
key={option.text} key={option.text}
onClick={(event) => props.onClick(option.value, event)} icon={option.icon}
className={clsx({
active: option.active ?? props.value === option.value,
})}
data-testid={option.testId}
title={option.text} title={option.text}
> testId={option.testId}
{option.icon} active={option.active ?? props.value === option.value}
</button> onClick={(event) => props.onClick(option.value, event)}
/>
) : ( ) : (
<label <label
key={option.text} key={option.text}
@@ -0,0 +1,10 @@
export const ButtonSeparator = () => (
<div
style={{
width: 1,
height: "1rem",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
);
@@ -20,7 +20,7 @@
align-items: center; align-items: center;
@include isMobile { @include isMobile {
max-width: 175px; max-width: 11rem;
} }
} }
@@ -1,22 +1,24 @@
import { isInteractive, isTransparent, isWritableElement } from "../../utils"; import { isTransparent } from "../../utils";
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useDevice, useExcalidrawContainer } from "../App"; import { useExcalidrawContainer } from "../App";
import type { ColorTuple, ColorPaletteCustom } from "../../colors"; import type { ColorTuple, ColorPaletteCustom } from "../../colors";
import { COLOR_PALETTE } from "../../colors"; import { COLOR_PALETTE } from "../../colors";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
import { t } from "../../i18n"; import { t } from "../../i18n";
import clsx from "clsx"; import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
@@ -71,6 +73,7 @@ const ColorPickerPopupContent = ({
| "palette" | "palette"
| "updateData" | "updateData"
>) => { >) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom( const [eyeDropperState, setEyeDropperState] = useAtom(
@@ -78,9 +81,6 @@ const ColorPickerPopupContent = ({
jotaiScope, jotaiScope,
); );
const { container } = useExcalidrawContainer();
const device = useDevice();
const colorInputJSX = ( const colorInputJSX = (
<div> <div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading> <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
@@ -94,6 +94,7 @@ const ColorPickerPopupContent = ({
/> />
</div> </div>
); );
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => { const focusPickerContent = () => {
@@ -103,120 +104,73 @@ const ColorPickerPopupContent = ({
}; };
return ( return (
<Popover.Portal container={container}> <PropertiesPopover
<Popover.Content container={container}
ref={popoverRef} style={{ maxWidth: "208px" }}
className="focus-visible-none" onFocusOutside={(event) => {
data-prevent-outside-click // refocus due to eye dropper
onFocusOutside={(event) => { focusPickerContent();
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)
event.preventDefault(); 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)
event.preventDefault();
}
}}
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();
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
} }
align={ }}
device.editor.isMobile && !device.viewport.isLandscape onClose={() => {
? "center" updateData({ openPopup: null });
: "start" setActiveColorPickerSection(null);
} }}
alignOffset={-16} >
sideOffset={20} {palette ? (
style={{ <Picker
zIndex: "var(--zIndex-layerUI)", palette={palette}
backgroundColor: "var(--popup-bg-color)", color={color}
maxWidth: "208px", onChange={(changedColor) => {
maxHeight: window.innerHeight, onChange(changedColor);
padding: "12px", }}
borderRadius: "8px", onEyeDropperToggle={(force) => {
boxSizing: "border-box", setEyeDropperState((state) => {
overflowY: "auto", if (force) {
boxShadow: state = state || {
"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)", keepOpenOnAlt: true,
}} onSelect: onChange,
> colorPickerType: type,
{palette ? ( };
<Picker state.keepOpenOnAlt = true;
palette={palette} return state;
color={color} }
onChange={(changedColor) => {
onChange(changedColor); return force === false || state
}} ? null
onEyeDropperToggle={(force) => { : {
setEyeDropperState((state) => { keepOpenOnAlt: false,
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange, onSelect: onChange,
colorPickerType: type, 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) => {
</Popover.Content> if (eyeDropperState) {
</Popover.Portal> setEyeDropperState(null);
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
); );
}; };
@@ -232,7 +186,7 @@ const ColorPickerTrigger = ({
return ( return (
<Popover.Trigger <Popover.Trigger
type="button" type="button"
className={clsx("color-picker__button active-color", { className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color, "is-transparent": color === "transparent" || !color,
})} })}
aria-label={label} aria-label={label}
@@ -268,14 +222,7 @@ export const ColorPicker = ({
type={type} type={type}
topPicks={topPicks} topPicks={topPicks}
/> />
<div <ButtonSeparator />
style={{
width: 1,
height: "100%",
backgroundColor: "var(--default-border-color)",
margin: "0 auto",
}}
/>
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -138,7 +138,7 @@ export const Picker = ({
event.stopPropagation(); event.stopPropagation();
} }
}} }}
className="color-picker-content" className="color-picker-content properties-content"
// to allow focusing by clicking but not by tabbing // to allow focusing by clicking but not by tabbing
tabIndex={-1} tabIndex={-1}
> >
@@ -0,0 +1,15 @@
@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
}
}
}
@@ -0,0 +1,110 @@
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,
);
@@ -0,0 +1,268 @@
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, fontFaces }]) => {
const font = {
value: familyId,
icon: metadata.icon,
text: fontFaces[0].fontFace.family,
};
if (metadata.deprecated) {
Object.assign(font, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
}
return font as FontDescriptor;
})
.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
),
[],
);
const sceneFamilies = useMemo(
() => new Set(fonts.sceneFamilies),
// 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,
);
@@ -0,0 +1,38 @@
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>
);
};
@@ -0,0 +1,66 @@
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;
}
};
@@ -8,7 +8,7 @@
h3 { h3 {
margin: 1.5rem 0; margin: 1.5rem 0;
font-weight: bold; font-weight: 700;
font-size: 1.125rem; font-size: 1.125rem;
} }
@@ -82,7 +82,7 @@
&__island { &__island {
h4 { h4 {
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: 700;
margin: 0; margin: 0;
margin-bottom: 0.625rem; margin-bottom: 0.625rem;
} }
@@ -458,6 +458,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.showBackground")} label={t("labels.showBackground")}
shortcuts={[getShortcutKey("G")]} shortcuts={[getShortcutKey("G")]}
/> />
<Shortcut
label={t("labels.showFonts")}
shortcuts={[getShortcutKey("Shift+F")]}
/>
<Shortcut <Shortcut
label={t("labels.decreaseFontSize")} label={t("labels.decreaseFontSize")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
@@ -11,7 +11,7 @@
.library-actions-counter { .library-actions-counter {
background-color: var(--color-primary); background-color: var(--color-primary);
color: var(--color-primary-light); color: var(--color-primary-light);
font-weight: bold; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -13,7 +13,7 @@
&__label { &__label {
color: var(--color-primary); color: var(--color-primary);
font-weight: bold; font-weight: 700;
font-size: 1.125rem; font-size: 1.125rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
@@ -62,7 +62,7 @@
&__header { &__header {
color: var(--color-primary); color: var(--color-primary);
font-size: 1.125rem; font-size: 1.125rem;
font-weight: bold; font-weight: 700;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
width: 100%; width: 100%;
padding-right: 4rem; // due to dropdown button padding-right: 4rem; // due to dropdown button
@@ -0,0 +1,96 @@
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>
);
},
);
@@ -133,7 +133,7 @@
.required, .required,
.error { .error {
color: $oc-red-8; color: $oc-red-8;
font-weight: bold; font-weight: 700;
font-size: 1rem; font-size: 1rem;
margin: 0.2rem; margin: 0.2rem;
} }
@@ -0,0 +1,48 @@
.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;
}
}
}
@@ -0,0 +1,28 @@
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>
);
},
);
@@ -0,0 +1,21 @@
.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%;
}
}
}
@@ -0,0 +1,24 @@
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>
);
};
+59 -43
View File
@@ -1,67 +1,80 @@
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement"; import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks"; import { isArrowElement } from "../../element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math"; import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface AngleProps { interface AngleProps {
element: ExcalidrawElement; element: ExcalidrawElement;
elementsMap: ElementsMap; scene: Scene;
appState: AppState;
property: "angle";
} }
const STEP_SIZE = 15; const STEP_SIZE = 15;
const Angle = ({ element, elementsMap }: AngleProps) => { const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
const handleDegreeChange: DragInputCallbackType = ({ accumulatedChange,
accumulatedChange, originalElements,
originalElements, shouldChangeByStepSize,
shouldChangeByStepSize, nextValue,
nextValue, scene,
}) => { }) => {
const origElement = originalElements[0]; const elementsMap = scene.getNonDeletedElementsMap();
if (origElement) { const origElement = originalElements[0];
if (nextValue !== undefined) { if (origElement) {
const nextAngle = degreeToRadian(nextValue); const latestElement = elementsMap.get(origElement.id);
mutateElement(element, { if (!latestElement) {
angle: nextAngle, return;
}); }
const boundTextElement = getBoundTextElement(element, elementsMap); if (nextValue !== undefined) {
if (boundTextElement && !isArrowElement(element)) { const nextAngle = degreeToRadian(nextValue);
mutateElement(boundTextElement, { angle: nextAngle }); mutateElement(latestElement, {
}
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, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(element)) { if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }); mutateElement(boundTextElement, { angle: nextAngle });
} }
}
};
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(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
}
};
const Angle = ({ element, scene, appState, property }: AngleProps) => {
return ( return (
<DragInput <DragInput
label="A" label="A"
@@ -70,6 +83,9 @@ const Angle = ({ element, elementsMap }: AngleProps) => {
elements={[element]} elements={[element]}
dragInputCallback={handleDegreeChange} dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")} editable={isPropertyEditable(element, "angle")}
scene={scene}
appState={appState}
property={property}
/> />
); );
}; };
@@ -1,13 +1,16 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils"; import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface DimensionDragInputProps { interface DimensionDragInputProps {
property: "width" | "height"; property: "width" | "height";
element: ExcalidrawElement; element: ExcalidrawElement;
elementsMap: ElementsMap; scene: Scene;
appState: AppState;
} }
const STEP_SIZE = 10; const STEP_SIZE = 10;
@@ -15,99 +18,101 @@ const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
return element.type === "image"; return element.type === "image";
}; };
const DimensionDragInput = ({ const handleDimensionChange: DragInputCallbackType<
DimensionDragInputProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
property, property,
element, scene,
elementsMap, }) => {
}: DimensionDragInputProps) => { const elementsMap = scene.getNonDeletedElementsMap();
const handleDimensionChange: DragInputCallbackType = ({ const origElement = originalElements[0];
accumulatedChange, if (origElement) {
originalElements, const keepAspectRatio =
originalElementsMap, shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
shouldKeepAspectRatio, const aspectRatio = origElement.width / origElement.height;
shouldChangeByStepSize,
nextValue,
}) => {
const origElement = originalElements[0];
if (origElement) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
const aspectRatio = origElement.width / origElement.height;
if (nextValue !== undefined) { if (nextValue !== undefined) {
const nextWidth = Math.max( const nextWidth = Math.max(
property === "width" property === "width"
? nextValue ? nextValue
: keepAspectRatio : keepAspectRatio
? nextValue * aspectRatio ? nextValue * aspectRatio
: origElement.width, : origElement.width,
MIN_WIDTH_OR_HEIGHT, MIN_WIDTH_OR_HEIGHT,
); );
const nextHeight = Math.max( const nextHeight = Math.max(
property === "height" property === "height"
? nextValue ? nextValue
: keepAspectRatio : keepAspectRatio
? nextValue / aspectRatio ? nextValue / aspectRatio
: origElement.height, : origElement.height,
MIN_WIDTH_OR_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( resizeElement(
nextWidth, nextWidth,
nextHeight, nextHeight,
keepAspectRatio, keepAspectRatio,
element,
origElement, origElement,
elementsMap, 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,
origElement,
elementsMap,
);
}
};
const DimensionDragInput = ({
property,
element,
scene,
appState,
}: DimensionDragInputProps) => {
const value = const value =
Math.round((property === "width" ? element.width : element.height) * 100) / Math.round((property === "width" ? element.width : element.height) * 100) /
100; 100;
@@ -119,6 +124,9 @@ const DimensionDragInput = ({
dragInputCallback={handleDimensionChange} dragInputCallback={handleDimensionChange}
value={value} value={value}
editable={isPropertyEditable(element, property)} editable={isPropertyEditable(element, property)}
scene={scene}
appState={appState}
property={property}
/> />
); );
}; };
@@ -3,43 +3,54 @@ import { EVENT } from "../../constants";
import { KEYS } from "../../keys"; import { KEYS } from "../../keys";
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { deepCopyElement } from "../../element/newElement"; import { deepCopyElement } from "../../element/newElement";
import "./DragInput.scss";
import clsx from "clsx"; import clsx from "clsx";
import { useApp } from "../App"; import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import type { StatsInputProperty } from "./utils";
import { SMALLEST_DELTA } from "./utils"; import { SMALLEST_DELTA } from "./utils";
import { StoreAction } from "../../store"; import { StoreAction } from "../../store";
import type Scene from "../../scene/Scene";
export type DragInputCallbackType = ({ import "./DragInput.scss";
accumulatedChange, import type { AppState } from "../../types";
instantChange, import { cloneJSON } from "../../utils";
originalElements,
originalElementsMap, export type DragInputCallbackType<
shouldKeepAspectRatio, P extends StatsInputProperty,
shouldChangeByStepSize, E = ExcalidrawElement,
nextValue, > = (props: {
}: {
accumulatedChange: number; accumulatedChange: number;
instantChange: number; instantChange: number;
originalElements: readonly ExcalidrawElement[]; originalElements: readonly E[];
originalElementsMap: ElementsMap; originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean; shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean; shouldChangeByStepSize: boolean;
nextValue?: number; nextValue?: number;
property: P;
scene: Scene;
originalAppState: AppState;
}) => void; }) => void;
interface StatsDragInputProps { interface StatsDragInputProps<
T extends StatsInputProperty,
E = ExcalidrawElement,
> {
label: string | React.ReactNode; label: string | React.ReactNode;
icon?: React.ReactNode; icon?: React.ReactNode;
value: number | "Mixed"; value: number | "Mixed";
elements: readonly ExcalidrawElement[]; elements: readonly E[];
editable?: boolean; editable?: boolean;
shouldKeepAspectRatio?: boolean; shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType; dragInputCallback: DragInputCallbackType<T, E>;
property: T;
scene: Scene;
appState: AppState;
} }
const StatsDragInput = ({ const StatsDragInput = <
T extends StatsInputProperty,
E extends ExcalidrawElement = ExcalidrawElement,
>({
label, label,
icon, icon,
dragInputCallback, dragInputCallback,
@@ -47,19 +58,48 @@ const StatsDragInput = ({
elements, elements,
editable = true, editable = true,
shouldKeepAspectRatio, shouldKeepAspectRatio,
}: StatsDragInputProps) => { property,
scene,
appState,
}: StatsDragInputProps<T, E>) => {
const app = useApp(); const app = useApp();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null); const labelRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState(value.toString()); const [inputValue, setInputValue] = useState(value.toString());
useEffect(() => { const stateRef = useRef<{
setInputValue(value.toString()); originalAppState: AppState;
}, [value, elements]); originalElements: readonly E[];
lastUpdatedValue: string;
updatePending: boolean;
}>(null!);
if (!stateRef.current) {
stateRef.current = {
originalAppState: cloneJSON(appState),
originalElements: elements,
lastUpdatedValue: inputValue,
updatePending: false,
};
}
const handleInputValue = (v: string) => { useEffect(() => {
const parsed = Number(v); const inputValue = value.toString();
setInputValue(inputValue);
stateRef.current.lastUpdatedValue = inputValue;
}, [value]);
const handleInputValue = (
updatedValue: string,
elements: readonly E[],
appState: AppState,
) => {
if (!stateRef.current.updatePending) {
return false;
}
stateRef.current.updatePending = false;
const parsed = Number(updatedValue);
if (isNaN(parsed)) { if (isNaN(parsed)) {
setInputValue(value.toString()); setInputValue(value.toString());
return; return;
@@ -74,6 +114,7 @@ const StatsDragInput = ({
// than the smallest delta allowed, which is 0.01 // than the smallest delta allowed, which is 0.01
// reason: idempotent to avoid unnecessary // reason: idempotent to avoid unnecessary
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) { if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
stateRef.current.lastUpdatedValue = updatedValue;
dragInputCallback({ dragInputCallback({
accumulatedChange: 0, accumulatedChange: 0,
instantChange: 0, instantChange: 0,
@@ -82,6 +123,9 @@ const StatsDragInput = ({
shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false, shouldChangeByStepSize: false,
nextValue: rounded, nextValue: rounded,
property,
scene,
originalAppState: appState,
}); });
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.syncActionResult({ storeAction: StoreAction.CAPTURE });
} }
@@ -97,12 +141,28 @@ const StatsDragInput = ({
return () => { return () => {
const nextValue = input?.value; const nextValue = input?.value;
if (nextValue) { if (nextValue) {
handleInputValueRef.current(nextValue); handleInputValueRef.current(
nextValue,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
} }
}; };
}, []); }, [
// 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,
]);
return editable ? ( if (!editable) {
return null;
}
return (
<div <div
className={clsx("drag-input-container", !editable && "disabled")} className={clsx("drag-input-container", !editable && "disabled")}
data-testid={label} data-testid={label}
@@ -122,30 +182,25 @@ const StatsDragInput = ({
y: number; y: number;
} | null = null; } | null = null;
let originalElements: ExcalidrawElement[] | null = null;
let originalElementsMap: Map<string, ExcalidrawElement> | null = let originalElementsMap: Map<string, ExcalidrawElement> | null =
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);
let accumulatedChange: number | null = null; let accumulatedChange: number | null = null;
document.body.classList.add("excalidraw-cursor-resize"); document.body.classList.add("excalidraw-cursor-resize");
const onPointerMove = (event: PointerEvent) => { 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) { if (!accumulatedChange) {
accumulatedChange = 0; accumulatedChange = 0;
} }
@@ -153,6 +208,7 @@ const StatsDragInput = ({
if ( if (
lastPointer && lastPointer &&
originalElementsMap !== null && originalElementsMap !== null &&
originalElements !== null &&
accumulatedChange !== null accumulatedChange !== null
) { ) {
const instantChange = event.clientX - lastPointer.x; const instantChange = event.clientX - lastPointer.x;
@@ -165,6 +221,9 @@ const StatsDragInput = ({
originalElementsMap, originalElementsMap,
shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: event.shiftKey, shouldChangeByStepSize: event.shiftKey,
property,
scene,
originalAppState,
}); });
} }
@@ -216,7 +275,7 @@ const StatsDragInput = ({
eventTarget instanceof HTMLInputElement && eventTarget instanceof HTMLInputElement &&
event.key === KEYS.ENTER event.key === KEYS.ENTER
) { ) {
handleInputValue(eventTarget.value); handleInputValue(eventTarget.value, elements, appState);
app.focusContainer(); app.focusContainer();
} }
} }
@@ -224,23 +283,28 @@ const StatsDragInput = ({
ref={inputRef} ref={inputRef}
value={inputValue} value={inputValue}
onChange={(event) => { onChange={(event) => {
stateRef.current.updatePending = true;
setInputValue(event.target.value); setInputValue(event.target.value);
}} }}
onFocus={(event) => { onFocus={(event) => {
event.target.select(); event.target.select();
stateRef.current.originalElements = elements;
stateRef.current.originalAppState = cloneJSON(appState);
}} }}
onBlur={(event) => { onBlur={(event) => {
if (!inputValue) { if (!inputValue) {
setInputValue(value.toString()); setInputValue(value.toString());
} else if (editable) { } else if (editable) {
handleInputValue(event.target.value); handleInputValue(
event.target.value,
stateRef.current.originalElements,
stateRef.current.originalAppState,
);
} }
}} }}
disabled={!editable} disabled={!editable}
/> />
</div> </div>
) : (
<></>
); );
}; };
@@ -1,73 +1,97 @@
import type { ElementsMap, ExcalidrawTextElement } from "../../element/types"; import type {
import { refreshTextDimensions } from "../../element/newElement"; ExcalidrawElement,
ExcalidrawTextElement,
} from "../../element/types";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getStepSizedValue } from "./utils"; import { getStepSizedValue } from "./utils";
import { fontSizeIcon } from "../icons"; 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 { interface FontSizeProps {
element: ExcalidrawTextElement; element: ExcalidrawElement;
elementsMap: ElementsMap; scene: Scene;
appState: AppState;
property: "fontSize";
} }
const MIN_FONT_SIZE = 4; const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4; const STEP_SIZE = 4;
const FontSize = ({ element, elementsMap }: FontSizeProps) => { const handleFontSizeChange: DragInputCallbackType<
const handleFontSizeChange: DragInputCallbackType = ({ FontSizeProps["property"],
accumulatedChange, ExcalidrawTextElement
originalElements, > = ({
shouldChangeByStepSize, accumulatedChange,
nextValue, originalElements,
}) => { shouldChangeByStepSize,
const origElement = originalElements[0]; nextValue,
if (origElement) { scene,
if (nextValue !== undefined) { }) => {
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); const elementsMap = scene.getNonDeletedElementsMap();
const newElement = { const origElement = originalElements[0];
...element, if (origElement) {
fontSize: nextFontSize, const latestElement = elementsMap.get(origElement.id);
}; if (!latestElement || !isTextElement(latestElement)) {
const updates = refreshTextDimensions(newElement, null, elementsMap); return;
mutateElement(element, { }
...updates,
fontSize: nextFontSize,
});
return;
}
if (origElement.type === "text") { let nextFontSize;
const originalFontSize = Math.round(origElement.fontSize);
const changeInFontSize = Math.round(accumulatedChange); if (nextValue !== undefined) {
let nextFontSize = Math.max( nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
originalFontSize + changeInFontSize, } else if (origElement.type === "text") {
MIN_FONT_SIZE, const originalFontSize = Math.round(origElement.fontSize);
); const changeInFontSize = Math.round(accumulatedChange);
if (shouldChangeByStepSize) { nextFontSize = Math.max(
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); originalFontSize + changeInFontSize,
} MIN_FONT_SIZE,
const newElement = { );
...element, if (shouldChangeByStepSize) {
fontSize: nextFontSize, nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
};
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 ( return (
<StatsDragInput <StatsDragInput
label="F" label="F"
value={Math.round(element.fontSize * 10) / 10} value={Math.round(_element.fontSize * 10) / 10}
elements={[element]} elements={[_element]}
dragInputCallback={handleFontSizeChange} dragInputCallback={handleFontSizeChange}
icon={fontSizeIcon} icon={fontSizeIcon}
appState={appState}
scene={scene}
property={property}
/> />
); );
}; };
@@ -1,7 +1,7 @@
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement"; import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks"; import { isArrowElement } from "../../element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import { degreeToRadian, radianToDegree } from "../../math"; import { degreeToRadian, radianToDegree } from "../../math";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
@@ -9,84 +9,102 @@ import { angleIcon } from "../icons";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { AppState } from "../../types";
interface MultiAngleProps { interface MultiAngleProps {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
scene: Scene; scene: Scene;
appState: AppState;
property: "angle";
} }
const STEP_SIZE = 15; const STEP_SIZE = 15;
const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => { const handleDegreeChange: DragInputCallbackType<
const handleDegreeChange: DragInputCallbackType = ({ MultiAngleProps["property"]
accumulatedChange, > = ({
originalElements, accumulatedChange,
shouldChangeByStepSize, originalElements,
nextValue, shouldChangeByStepSize,
}) => { nextValue,
const editableLatestIndividualElements = elements.filter( property,
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"), scene,
); }) => {
const editableOriginalIndividualElements = originalElements.filter( const elementsMap = scene.getNonDeletedElementsMap();
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"), 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),
);
if (nextValue !== undefined) { if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue); const nextAngle = degreeToRadian(nextValue);
for (const element of editableLatestIndividualElements) { for (const element of editableLatestIndividualElements) {
mutateElement( if (!element) {
element, continue;
{
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( mutateElement(
latestElement, element,
{ {
angle: nextAngle, angle: nextAngle,
}, },
false, false,
); );
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle }, false); mutateElement(boundTextElement, { angle: nextAngle }, false);
} }
} }
scene.triggerUpdate();
};
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( const editableLatestIndividualElements = elements.filter(
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"), (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
); );
@@ -107,6 +125,9 @@ const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
elements={elements} elements={elements}
dragInputCallback={handleDegreeChange} dragInputCallback={handleDegreeChange}
editable={editable} editable={editable}
appState={appState}
scene={scene}
property={property}
/> />
); );
}; };
@@ -7,12 +7,16 @@ import {
getBoundTextElement, getBoundTextElement,
handleBindTextResize, handleBindTextResize,
} from "../../element/textElement"; } from "../../element/textElement";
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type {
ElementsMap,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { Point } from "../../types"; import type { AppState, Point } from "../../types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
@@ -20,9 +24,10 @@ import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
interface MultiDimensionProps { interface MultiDimensionProps {
property: "width" | "height"; property: "width" | "height";
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap; elementsMap: NonDeletedSceneElementsMap;
atomicUnits: AtomicUnit[]; atomicUnits: AtomicUnit[];
scene: Scene; scene: Scene;
appState: AppState;
} }
const STEP_SIZE = 10; const STEP_SIZE = 10;
@@ -59,7 +64,7 @@ const resizeElementInGroup = (
scale: number, scale: number,
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
@@ -102,7 +107,7 @@ const resizeGroup = (
property: MultiDimensionProps["property"], property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[], latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
// keep aspect ratio for groups // keep aspect ratio for groups
@@ -131,12 +136,205 @@ const resizeGroup = (
} }
}; };
const handleDimensionChange: DragInputCallbackType<
MultiDimensionProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
originalAppState,
shouldChangeByStepSize,
nextValue,
scene,
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
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,
);
} 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,
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, origElement, elementsMap);
}
}
}
scene.triggerUpdate();
};
const MultiDimension = ({ const MultiDimension = ({
property, property,
elements, elements,
elementsMap, elementsMap,
atomicUnits, atomicUnits,
scene, scene,
appState,
}: MultiDimensionProps) => { }: MultiDimensionProps) => {
const sizes = useMemo( const sizes = useMemo(
() => () =>
@@ -167,202 +365,6 @@ const MultiDimension = ({
const editable = sizes.length > 0; 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 ( return (
<DragInput <DragInput
label={property === "width" ? "W" : "H"} label={property === "width" ? "W" : "H"}
@@ -370,6 +372,9 @@ const MultiDimension = ({
dragInputCallback={handleDimensionChange} dragInputCallback={handleDimensionChange}
value={value} value={value}
editable={editable} editable={editable}
appState={appState}
property={property}
scene={scene}
/> />
); );
}; };
@@ -1,10 +1,10 @@
import { isTextElement, refreshTextDimensions } from "../../element"; import { isTextElement, redrawTextBoundingBox } from "../../element";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { isBoundToContainer } from "../../element/typeChecks"; import { hasBoundTextElement } from "../../element/typeChecks";
import type { import type {
ElementsMap,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedSceneElementsMap,
} from "../../element/types"; } from "../../element/types";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
@@ -12,62 +12,87 @@ import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue } from "./utils"; import { getStepSizedValue } from "./utils";
import type { AppState } from "../../types";
import { getBoundTextElement } from "../../element/textElement";
interface MultiFontSizeProps { interface MultiFontSizeProps {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
scene: Scene; scene: Scene;
elementsMap: NonDeletedSceneElementsMap;
appState: AppState;
property: "fontSize";
} }
const MIN_FONT_SIZE = 4; const MIN_FONT_SIZE = 4;
const STEP_SIZE = 4; const STEP_SIZE = 4;
const MultiFontSize = ({ const getApplicableTextElements = (
elements, elements: readonly (ExcalidrawElement | undefined)[],
elementsMap, elementsMap: NonDeletedSceneElementsMap,
scene, ) =>
}: MultiFontSizeProps) => { elements.reduce(
const latestTextElements = elements.filter( (acc: ExcalidrawTextElement[], el) => {
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el), if (!el || isInGroup(el)) {
) as ExcalidrawTextElement[]; return acc;
const fontSizes = latestTextElements.map( }
(textEl) => Math.round(textEl.fontSize * 10) / 10, if (isTextElement(el)) {
); acc.push(el);
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed"; return acc;
const editable = fontSizes.length > 0; }
if (hasBoundTextElement(el)) {
const handleFontSizeChange: DragInputCallbackType = ({ const boundTextElement = getBoundTextElement(el, elementsMap);
accumulatedChange, if (boundTextElement) {
originalElements, acc.push(boundTextElement);
shouldChangeByStepSize, return acc;
nextValue, }
}) => {
if (nextValue) {
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
for (const textElement of latestTextElements) {
const newElement = {
...textElement,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement(
textElement,
{
...updates,
fontSize: nextFontSize,
},
false,
);
} }
scene.triggerUpdate(); return acc;
return; },
[],
);
const handleFontSizeChange: DragInputCallbackType<
MultiFontSizeProps["property"],
ExcalidrawTextElement
> = ({
accumulatedChange,
originalElements,
shouldChangeByStepSize,
nextValue,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const latestTextElements = originalElements.map((el) =>
elementsMap.get(el.id),
) as ExcalidrawTextElement[];
let nextFontSize;
if (nextValue) {
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
for (const textElement of latestTextElements) {
mutateElement(
textElement,
{
fontSize: nextFontSize,
},
false,
);
redrawTextBoundingBox(
textElement,
scene.getContainerElement(textElement),
elementsMap,
false,
);
} }
const originalTextElements = originalElements.filter( scene.triggerUpdate();
(el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el), } else {
) as ExcalidrawTextElement[]; const originalTextElements = originalElements as ExcalidrawTextElement[];
for (let i = 0; i < latestTextElements.length; i++) { for (let i = 0; i < latestTextElements.length; i++) {
const latestElement = latestTextElements[i]; const latestElement = latestTextElements[i];
@@ -82,32 +107,56 @@ const MultiFontSize = ({
if (shouldChangeByStepSize) { if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
} }
const newElement = {
...latestElement,
fontSize: nextFontSize,
};
const updates = refreshTextDimensions(newElement, null, elementsMap);
mutateElement( mutateElement(
latestElement, latestElement,
{ {
...updates,
fontSize: nextFontSize, fontSize: nextFontSize,
}, },
false, false,
); );
redrawTextBoundingBox(
latestElement,
scene.getContainerElement(latestElement),
elementsMap,
false,
);
} }
scene.triggerUpdate(); 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 ( return (
<StatsDragInput <StatsDragInput
label="F" label="F"
icon={fontSizeIcon} icon={fontSizeIcon}
elements={elements} elements={latestTextElements}
dragInputCallback={handleFontSizeChange} dragInputCallback={handleFontSizeChange}
value={value} value={value}
editable={editable} editable={editable}
scene={scene}
property={property}
appState={appState}
/> />
); );
}; };
@@ -1,13 +1,18 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type {
ElementsMap,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math"; import { rotate } from "../../math";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getCommonBounds, isTextElement } from "../../element"; import { getCommonBounds, isTextElement } from "../../element";
import { useMemo } from "react"; import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils"; import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
interface MultiPositionProps { interface MultiPositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -15,6 +20,7 @@ interface MultiPositionProps {
elementsMap: ElementsMap; elementsMap: ElementsMap;
atomicUnits: AtomicUnit[]; atomicUnits: AtomicUnit[];
scene: Scene; scene: Scene;
appState: AppState;
} }
const STEP_SIZE = 10; const STEP_SIZE = 10;
@@ -25,12 +31,11 @@ const moveElements = (
changeInTopY: number, changeInTopY: number,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
originalElements: readonly ExcalidrawElement[], originalElements: readonly ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i]; const origElement = originalElements[i];
const latestElement = elements[i];
const [cx, cy] = [ const [cx, cy] = [
origElement.x + origElement.width / 2, origElement.x + origElement.width / 2,
@@ -53,7 +58,6 @@ const moveElements = (
moveElement( moveElement(
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
latestElement,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, originalElementsMap,
@@ -65,18 +69,22 @@ const moveElements = (
const moveGroupTo = ( const moveGroupTo = (
nextX: number, nextX: number,
nextY: number, nextY: number,
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
) => { ) => {
const [x1, y1, ,] = getCommonBounds(originalElements); const [x1, y1, ,] = getCommonBounds(originalElements);
const offsetX = nextX - x1; const offsetX = nextX - x1;
const offsetY = nextY - y1; const offsetY = nextY - y1;
for (let i = 0; i < latestElements.length; i++) { for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i]; const origElement = originalElements[i];
const latestElement = latestElements[i];
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
continue;
}
// bound texts are moved with their containers // bound texts are moved with their containers
if (!isTextElement(latestElement) || !latestElement.containerId) { if (!isTextElement(latestElement) || !latestElement.containerId) {
@@ -96,7 +104,6 @@ const moveGroupTo = (
moveElement( moveElement(
topLeftX + offsetX, topLeftX + offsetX,
topLeftY + offsetY, topLeftY + offsetY,
latestElement,
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, originalElementsMap,
@@ -106,12 +113,111 @@ const moveGroupTo = (
} }
}; };
const handlePositionChange: DragInputCallbackType<
MultiPositionProps["property"]
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
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,
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,
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.triggerUpdate();
};
const MultiPosition = ({ const MultiPosition = ({
property, property,
elements, elements,
elementsMap, elementsMap,
atomicUnits, atomicUnits,
scene, scene,
appState,
}: MultiPositionProps) => { }: MultiPositionProps) => {
const positions = useMemo( const positions = useMemo(
() => () =>
@@ -137,101 +243,15 @@ const MultiPosition = ({
const value = new Set(positions).size === 1 ? positions[0] : "Mixed"; 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 ( return (
<StatsDragInput <StatsDragInput
label={property === "x" ? "X" : "Y"} label={property === "x" ? "X" : "Y"}
elements={elements} elements={elements}
dragInputCallback={handlePositionChange} dragInputCallback={handlePositionChange}
value={value} value={value}
property={property}
scene={scene}
appState={appState}
/> />
); );
}; };
@@ -3,16 +3,92 @@ import { rotate } from "../../math";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils"; import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface PositionProps { interface PositionProps {
property: "x" | "y"; property: "x" | "y";
element: ExcalidrawElement; element: ExcalidrawElement;
elementsMap: ElementsMap; elementsMap: ElementsMap;
scene: Scene;
appState: AppState;
} }
const STEP_SIZE = 10; const STEP_SIZE = 10;
const Position = ({ property, element, elementsMap }: PositionProps) => { const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
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,
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,
originalElementsMap,
);
};
const Position = ({
property,
element,
elementsMap,
scene,
appState,
}: PositionProps) => {
const [topLeftX, topLeftY] = rotate( const [topLeftX, topLeftY] = rotate(
element.x, element.x,
element.y, element.y,
@@ -23,77 +99,15 @@ const Position = ({ property, element, elementsMap }: PositionProps) => {
const value = const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100; 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 ( return (
<StatsDragInput <StatsDragInput
label={property === "x" ? "X" : "Y"} label={property === "x" ? "X" : "Y"}
elements={[element]} elements={[element]}
dragInputCallback={handlePositionChange} dragInputCallback={handlePositionChange}
value={value} value={value}
property={property}
scene={scene}
appState={appState}
/> />
); );
}; };
+29 -33
View File
@@ -11,12 +11,7 @@ import Angle from "./Angle";
import FontSize from "./FontSize"; import FontSize from "./FontSize";
import MultiDimension from "./MultiDimension"; import MultiDimension from "./MultiDimension";
import { import { elementsAreInSameGroup } from "../../groups";
elementsAreInSameGroup,
getElementsInGroup,
getSelectedGroupIds,
isInGroup,
} from "../../groups";
import MultiAngle from "./MultiAngle"; import MultiAngle from "./MultiAngle";
import MultiFontSize from "./MultiFontSize"; import MultiFontSize from "./MultiFontSize";
import Position from "./Position"; import Position from "./Position";
@@ -24,7 +19,7 @@ import MultiPosition from "./MultiPosition";
import Collapsible from "./Collapsible"; import Collapsible from "./Collapsible";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import type { AtomicUnit } from "./utils"; import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants"; import { STATS_PANELS } from "../../constants";
interface StatsProps { interface StatsProps {
@@ -106,21 +101,7 @@ export const StatsInner = memo(
); );
const atomicUnits = useMemo(() => { const atomicUnits = useMemo(() => {
const selectedGroupIds = getSelectedGroupIds(appState); return getAtomicUnits(selectedElements, 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]); }, [selectedElements, appState]);
return ( return (
@@ -206,32 +187,40 @@ export const StatsInner = memo(
element={singleElement} element={singleElement}
property="x" property="x"
elementsMap={elementsMap} elementsMap={elementsMap}
scene={scene}
appState={appState}
/> />
<Position <Position
element={singleElement} element={singleElement}
property="y" property="y"
elementsMap={elementsMap} elementsMap={elementsMap}
scene={scene}
appState={appState}
/> />
<Dimension <Dimension
property="width" property="width"
element={singleElement} element={singleElement}
elementsMap={elementsMap} scene={scene}
appState={appState}
/> />
<Dimension <Dimension
property="height" property="height"
element={singleElement} element={singleElement}
elementsMap={elementsMap} scene={scene}
appState={appState}
/> />
<Angle <Angle
property="angle"
element={singleElement} element={singleElement}
elementsMap={elementsMap} scene={scene}
appState={appState}
/>
<FontSize
property="fontSize"
element={singleElement}
scene={scene}
appState={appState}
/> />
{singleElement.type === "text" && (
<FontSize
element={singleElement}
elementsMap={elementsMap}
/>
)}
</div> </div>
</div> </div>
)} )}
@@ -254,6 +243,7 @@ export const StatsInner = memo(
elementsMap={elementsMap} elementsMap={elementsMap}
atomicUnits={atomicUnits} atomicUnits={atomicUnits}
scene={scene} scene={scene}
appState={appState}
/> />
<MultiPosition <MultiPosition
property="y" property="y"
@@ -261,6 +251,7 @@ export const StatsInner = memo(
elementsMap={elementsMap} elementsMap={elementsMap}
atomicUnits={atomicUnits} atomicUnits={atomicUnits}
scene={scene} scene={scene}
appState={appState}
/> />
<MultiDimension <MultiDimension
property="width" property="width"
@@ -268,6 +259,7 @@ export const StatsInner = memo(
elementsMap={elementsMap} elementsMap={elementsMap}
atomicUnits={atomicUnits} atomicUnits={atomicUnits}
scene={scene} scene={scene}
appState={appState}
/> />
<MultiDimension <MultiDimension
property="height" property="height"
@@ -275,16 +267,20 @@ export const StatsInner = memo(
elementsMap={elementsMap} elementsMap={elementsMap}
atomicUnits={atomicUnits} atomicUnits={atomicUnits}
scene={scene} scene={scene}
appState={appState}
/> />
<MultiAngle <MultiAngle
property="angle"
elements={multipleElements} elements={multipleElements}
elementsMap={elementsMap}
scene={scene} scene={scene}
appState={appState}
/> />
<MultiFontSize <MultiFontSize
property="fontSize"
elements={multipleElements} elements={multipleElements}
elementsMap={elementsMap}
scene={scene} scene={scene}
appState={appState}
elementsMap={elementsMap}
/> />
</div> </div>
</div> </div>
@@ -11,10 +11,11 @@ import * as StaticScene from "../../renderer/staticScene";
import { vi } from "vitest"; import { vi } from "vitest";
import { reseed } from "../../random"; import { reseed } from "../../random";
import { setDateTimeForTests } from "../../utils"; import { setDateTimeForTests } from "../../utils";
import { Excalidraw } from "../.."; import { Excalidraw, mutateElement } from "../..";
import { t } from "../../i18n"; import { t } from "../../i18n";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../../element/types"; } from "../../element/types";
import { degreeToRadian, rotate } from "../../math"; import { degreeToRadian, rotate } from "../../math";
@@ -23,6 +24,7 @@ import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api"; import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions"; import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import React from "react";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -30,11 +32,21 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null; let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = 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 getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) { if (elementStats) {
const properties = elementStats?.querySelector(".statsItem"); const properties = elementStats?.querySelector(".statsItem");
return properties?.querySelector?.( return (
`.drag-input-container[data-testid="${label}"]`, properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
); );
} }
@@ -51,11 +63,9 @@ const testInputProperty = (
const input = getStatsProperty(label)?.querySelector( const input = getStatsProperty(label)?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).not.toBeNull(); expect(input).toBeDefined();
expect(input.value).toBe(initialValue.toString()); expect(input.value).toBe(initialValue.toString());
input?.focus(); editInput(input, String(nextValue));
input.value = nextValue.toString();
input?.blur();
if (property === "angle") { if (property === "angle") {
expect(element[property]).toBe(degreeToRadian(Number(nextValue))); expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
} else if (property === "fontSize" && isTextElement(element)) { } else if (property === "fontSize" && isTextElement(element)) {
@@ -91,6 +101,92 @@ 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 // single element
describe("stats for a generic element", () => { describe("stats for a generic element", () => {
beforeEach(async () => { beforeEach(async () => {
@@ -127,8 +223,8 @@ describe("stats for a generic element", () => {
}); });
it("should open stats", () => { it("should open stats", () => {
expect(stats).not.toBeNull(); expect(stats).toBeDefined();
expect(elementStats).not.toBeNull(); expect(elementStats).toBeDefined();
// title // title
const title = elementStats?.querySelector("h3"); const title = elementStats?.querySelector("h3");
@@ -136,18 +232,18 @@ describe("stats for a generic element", () => {
// element type // element type
const elementType = elementStats?.querySelector(".elementType"); const elementType = elementStats?.querySelector(".elementType");
expect(elementType).not.toBeNull(); expect(elementType).toBeDefined();
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle")); expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
// properties // properties
const properties = elementStats?.querySelector(".statsItem"); const properties = elementStats?.querySelector(".statsItem");
expect(properties?.childNodes).not.toBeNull(); expect(properties?.childNodes).toBeDefined();
["X", "Y", "W", "H", "A"].forEach((label) => () => { ["X", "Y", "W", "H", "A"].forEach((label) => () => {
expect( expect(
properties?.querySelector?.( properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`, `.drag-input-container[data-testid="${label}"]`,
), ),
).not.toBeNull(); ).toBeDefined();
}); });
}); });
@@ -170,19 +266,15 @@ describe("stats for a generic element", () => {
const input = getStatsProperty("W")?.querySelector( const input = getStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).not.toBeNull(); expect(input).toBeDefined();
expect(input.value).toBe(rectangle.width.toString()); expect(input.value).toBe(rectangle.width.toString());
input?.focus(); editInput(input, "123.123");
input.value = "123.123";
input?.blur();
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
expect(rectangle.id).toBe(rectangleId); expect(rectangle.id).toBe(rectangleId);
expect(input.value).toBe("123.12"); expect(input.value).toBe("123.12");
expect(rectangle.width).toBe(123.12); expect(rectangle.width).toBe(123.12);
input?.focus(); editInput(input, "88.98766");
input.value = "88.98766";
input?.blur();
expect(input.value).toBe("88.99"); expect(input.value).toBe("88.99");
expect(rectangle.width).toBe(88.99); expect(rectangle.width).toBe(88.99);
}); });
@@ -333,11 +425,9 @@ describe("stats for a non-generic element", () => {
const input = getStatsProperty("F")?.querySelector( const input = getStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(input).not.toBeNull(); expect(input).toBeDefined();
expect(input.value).toBe(text.fontSize.toString()); expect(input.value).toBe(text.fontSize.toString());
input?.focus(); editInput(input, "36");
input.value = "36";
input?.blur();
expect(text.fontSize).toBe(36); expect(text.fontSize).toBe(36);
// cannot change width or height // cannot change width or height
@@ -347,9 +437,7 @@ describe("stats for a non-generic element", () => {
expect(height).toBeUndefined(); expect(height).toBeUndefined();
// min font size is 4 // min font size is 4
input.focus(); editInput(input, "0");
input.value = "0";
input.blur();
expect(text.fontSize).not.toBe(0); expect(text.fontSize).not.toBe(0);
expect(text.fontSize).toBe(4); expect(text.fontSize).toBe(4);
}); });
@@ -370,7 +458,7 @@ describe("stats for a non-generic element", () => {
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
expect(elementStats).not.toBeNull(); expect(elementStats).toBeDefined();
// cannot change angle // cannot change angle
const angle = getStatsProperty("A")?.querySelector(".drag-input"); const angle = getStatsProperty("A")?.querySelector(".drag-input");
@@ -391,7 +479,7 @@ describe("stats for a non-generic element", () => {
}, },
}); });
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
expect(elementStats).not.toBeNull(); expect(elementStats).toBeDefined();
const widthToHeight = image.width / image.height; const widthToHeight = image.width / image.height;
// when width or height is changed, the aspect ratio is preserved // when width or height is changed, the aspect ratio is preserved
@@ -403,6 +491,35 @@ describe("stats for a non-generic element", () => {
expect(image.height).toBe(80); expect(image.height).toBe(80);
expect(image.width / image.height).toBe(widthToHeight); 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 // multiple elements
@@ -471,16 +588,12 @@ describe("stats for multiple elements", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(angle.value).toBe("0"); expect(angle.value).toBe("0");
width.focus(); editInput(width, "250");
width.value = "250";
width.blur();
h.elements.forEach((el) => { h.elements.forEach((el) => {
expect(el.width).toBe(250); expect(el.width).toBe(250);
}); });
height.focus(); editInput(height, "450");
height.value = "450";
height.blur();
h.elements.forEach((el) => { h.elements.forEach((el) => {
expect(el.height).toBe(450); expect(el.height).toBe(450);
}); });
@@ -501,7 +614,6 @@ describe("stats for multiple elements", () => {
mouse.up(200, 100); mouse.up(200, 100);
const frame = API.createElement({ const frame = API.createElement({
id: "id0",
type: "frame", type: "frame",
x: 150, x: 150,
width: 150, width: 150,
@@ -524,38 +636,34 @@ describe("stats for multiple elements", () => {
const width = getStatsProperty("W")?.querySelector( const width = getStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width).not.toBeNull(); expect(width).toBeDefined();
expect(width.value).toBe("Mixed"); expect(width.value).toBe("Mixed");
const height = getStatsProperty("H")?.querySelector( const height = getStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height).not.toBeNull(); expect(height).toBeDefined();
expect(height.value).toBe("Mixed"); expect(height.value).toBe("Mixed");
const angle = getStatsProperty("A")?.querySelector( const angle = getStatsProperty("A")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(angle).not.toBeNull(); expect(angle).toBeDefined();
expect(angle.value).toBe("0"); expect(angle.value).toBe("0");
const fontSize = getStatsProperty("F")?.querySelector( const fontSize = getStatsProperty("F")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(fontSize).not.toBeNull(); expect(fontSize).toBeDefined();
// changing width does not affect text // changing width does not affect text
width.focus(); editInput(width, "200");
width.value = "200";
width.blur();
expect(rectangle?.width).toBe(200); expect(rectangle?.width).toBe(200);
expect(frame.width).toBe(200); expect(frame.width).toBe(200);
expect(text?.width).not.toBe(200); expect(text?.width).not.toBe(200);
angle.focus(); editInput(angle, "40");
angle.value = "40";
angle.blur();
const angleInRadian = degreeToRadian(40); const angleInRadian = degreeToRadian(40);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4); expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
@@ -592,12 +700,10 @@ describe("stats for multiple elements", () => {
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(x).not.toBeNull(); expect(x).toBeDefined();
expect(Number(x.value)).toBe(x1); expect(Number(x.value)).toBe(x1);
x.focus(); editInput(x, "300");
x.value = "300";
x.blur();
expect(h.elements[0].x).toBe(300); expect(h.elements[0].x).toBe(300);
expect(h.elements[1].x).toBe(400); expect(h.elements[1].x).toBe(400);
@@ -607,12 +713,10 @@ describe("stats for multiple elements", () => {
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(y).not.toBeNull(); expect(y).toBeDefined();
expect(Number(y.value)).toBe(y1); expect(Number(y.value)).toBe(y1);
y.focus(); editInput(y, "200");
y.value = "200";
y.blur();
expect(h.elements[0].y).toBe(200); expect(h.elements[0].y).toBe(200);
expect(h.elements[1].y).toBe(300); expect(h.elements[1].y).toBe(300);
@@ -621,35 +725,29 @@ describe("stats for multiple elements", () => {
const width = getStatsProperty("W")?.querySelector( const width = getStatsProperty("W")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(width).not.toBeNull(); expect(width).toBeDefined();
expect(Number(width.value)).toBe(200); expect(Number(width.value)).toBe(200);
const height = getStatsProperty("H")?.querySelector( const height = getStatsProperty("H")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(height).not.toBeNull(); expect(height).toBeDefined();
expect(Number(height.value)).toBe(200); expect(Number(height.value)).toBe(200);
width.focus(); editInput(width, "400");
width.value = "400";
width.blur();
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
let newGroupWidth = x2 - x1; let newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(400, 4); expect(newGroupWidth).toBeCloseTo(400, 4);
width.focus(); editInput(width, "300");
width.value = "300";
width.blur();
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
newGroupWidth = x2 - x1; newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(300, 4); expect(newGroupWidth).toBeCloseTo(300, 4);
height.focus(); editInput(height, "500");
height.value = "500";
height.blur();
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup); [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
const newGroupHeight = y2 - y1; const newGroupHeight = y2 - y1;
+77 -14
View File
@@ -1,4 +1,7 @@
import { updateBoundElements } from "../../element/binding"; import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "../../element/binding";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { import {
measureFontSizeFromWidth, measureFontSizeFromWidth,
@@ -11,15 +14,34 @@ import {
getBoundTextMaxWidth, getBoundTextMaxWidth,
handleBindTextResize, handleBindTextResize,
} from "../../element/textElement"; } from "../../element/textElement";
import { isFrameLikeElement, isTextElement } from "../../element/typeChecks"; import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "../../element/typeChecks";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types"; } from "../../element/types";
import {
getSelectedGroupIds,
getElementsInGroup,
isInGroup,
} from "../../groups";
import { rotate } from "../../math"; import { rotate } from "../../math";
import type { AppState } from "../../types";
import { getFontString } from "../../utils"; import { getFontString } from "../../utils";
export type StatsInputProperty =
| "x"
| "y"
| "width"
| "height"
| "angle"
| "fontSize";
export const SMALLEST_DELTA = 0.01; export const SMALLEST_DELTA = 0.01;
export const isPropertyEditable = ( export const isPropertyEditable = (
@@ -100,12 +122,14 @@ export const resizeElement = (
nextWidth: number, nextWidth: number,
nextHeight: number, nextHeight: number,
keepAspectRatio: boolean, keepAspectRatio: boolean,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: Map<string, ExcalidrawElement>,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
let boundTextFont: { fontSize?: number } = {}; let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
@@ -140,6 +164,12 @@ export const resizeElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) { if (boundTextElement) {
boundTextFont = { boundTextFont = {
@@ -163,13 +193,6 @@ export const resizeElement = (
} }
} }
updateBoundElements(latestElement, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
@@ -181,12 +204,15 @@ export const resizeElement = (
export const moveElement = ( export const moveElement = (
newTopLeftX: number, newTopLeftX: number,
newTopLeftY: number, newTopLeftY: number,
latestElement: ExcalidrawElement,
originalElement: ExcalidrawElement, originalElement: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
const latestElement = elementsMap.get(originalElement.id);
if (!latestElement) {
return;
}
const [cx, cy] = [ const [cx, cy] = [
originalElement.x + originalElement.width / 2, originalElement.x + originalElement.width / 2,
originalElement.y + originalElement.height / 2, originalElement.y + originalElement.height / 2,
@@ -218,6 +244,7 @@ export const moveElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
originalElement, originalElement,
@@ -236,3 +263,39 @@ 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,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
} else {
updateBoundElements(latestElement, elementsMap, options);
}
};
@@ -139,7 +139,7 @@ $verticalBreakpoint: 861px;
.ttd-dialog-output-error { .ttd-dialog-output-error {
color: red; color: red;
font-weight: 800; font-weight: 700;
font-size: 30px; font-size: 30px;
word-break: break-word; word-break: break-word;
overflow: auto; overflow: auto;
@@ -1,10 +1,6 @@
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw"; import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces"; import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import { import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
DEFAULT_EXPORT_PADDING,
DEFAULT_FONT_SIZE,
EDITOR_LS_KEYS,
} from "../../constants";
import { convertToExcalidrawElements, exportToCanvas } from "../../index"; import { convertToExcalidrawElements, exportToCanvas } from "../../index";
import type { NonDeletedExcalidrawElement } from "../../element/types"; import type { NonDeletedExcalidrawElement } from "../../element/types";
import type { AppClassProperties, BinaryFiles } from "../../types"; import type { AppClassProperties, BinaryFiles } from "../../types";
@@ -38,7 +34,7 @@ export interface MermaidToExcalidrawLibProps {
api: Promise<{ api: Promise<{
parseMermaidToExcalidraw: ( parseMermaidToExcalidraw: (
definition: string, definition: string,
options: MermaidOptions, config?: MermaidConfig,
) => Promise<MermaidToExcalidrawResult>; ) => Promise<MermaidToExcalidrawResult>;
}>; }>;
} }
@@ -78,15 +74,10 @@ export const convertMermaidToExcalidraw = async ({
let ret; let ret;
try { try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, { ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
fontSize: DEFAULT_FONT_SIZE,
});
} catch (err: any) { } catch (err: any) {
ret = await api.parseMermaidToExcalidraw( ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"), mermaidDefinition.replace(/"/g, "'"),
{
fontSize: DEFAULT_FONT_SIZE,
},
); );
} }
const { elements, files } = ret; const { elements, files } = ret;
+2 -64
View File
@@ -5,10 +5,11 @@
--avatarList-gap: 0.625rem; --avatarList-gap: 0.625rem;
--userList-padding: var(--space-factor); --userList-padding: var(--space-factor);
.UserList-wrapper { .UserList__wrapper {
display: flex; display: flex;
width: 100%; width: 100%;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
pointer-events: none !important; pointer-events: none !important;
} }
@@ -21,10 +22,6 @@
align-items: center; align-items: center;
gap: var(--avatarList-gap); gap: var(--avatarList-gap);
&:empty {
display: none;
}
box-sizing: border-box; box-sizing: border-box;
--max-size: calc( --max-size: calc(
@@ -157,66 +154,7 @@
} }
.UserList__collaborators { .UserList__collaborators {
position: static;
top: auto; top: auto;
margin-top: 0;
max-height: 50vh; 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;
}
} }
} }
+43 -50
View File
@@ -9,11 +9,12 @@ import type { ActionManager } from "../actions/manager";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { Island } from "./Island"; import { Island } from "./Island";
import { searchIcon } from "./icons"; import { QuickSearch } from "./QuickSearch";
import { t } from "../i18n"; import { t } from "../i18n";
import { isShallowEqual } from "../utils"; import { isShallowEqual } from "../utils";
import { supportsResizeObserver } from "../constants"; import { supportsResizeObserver } from "../constants";
import type { MarkRequired } from "../utility-types"; import type { MarkRequired } from "../utility-types";
import { ScrollableList } from "./ScrollableList";
export type GoToCollaboratorComponentProps = { export type GoToCollaboratorComponentProps = {
socketId: SocketId; socketId: SocketId;
@@ -40,7 +41,7 @@ const ConditionalTooltipWrapper = ({
shouldWrap ? ( shouldWrap ? (
<Tooltip label={username || "Unknown user"}>{children}</Tooltip> <Tooltip label={username || "Unknown user"}>{children}</Tooltip>
) : ( ) : (
<React.Fragment>{children}</React.Fragment> <>{children}</>
); );
const renderCollaborator = ({ const renderCollaborator = ({
@@ -128,6 +129,10 @@ export const UserList = React.memo(
).filter((collaborator) => collaborator.username?.trim()); ).filter((collaborator) => collaborator.username?.trim());
const [searchTerm, setSearchTerm] = React.useState(""); const [searchTerm, setSearchTerm] = React.useState("");
const filteredCollaborators = uniqueCollaboratorsArray.filter(
(collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
);
const userListWrapper = React.useRef<HTMLDivElement | null>(null); const userListWrapper = React.useRef<HTMLDivElement | null>(null);
@@ -161,14 +166,6 @@ export const UserList = React.memo(
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS); 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( const firstNCollaborators = uniqueCollaboratorsArray.slice(
0, 0,
maxAvatars - 1, maxAvatars - 1,
@@ -197,7 +194,7 @@ export const UserList = React.memo(
)} )}
</div> </div>
) : ( ) : (
<div className="UserList-wrapper" ref={userListWrapper}> <div className="UserList__wrapper" ref={userListWrapper}>
<div <div
className={clsx("UserList", className)} className={clsx("UserList", className)}
style={{ [`--max-avatars` as any]: maxAvatars }} style={{ [`--max-avatars` as any]: maxAvatars }}
@@ -205,13 +202,7 @@ export const UserList = React.memo(
{firstNAvatarsJSX} {firstNAvatarsJSX}
{uniqueCollaboratorsArray.length > maxAvatars - 1 && ( {uniqueCollaboratorsArray.length > maxAvatars - 1 && (
<Popover.Root <Popover.Root>
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
>
<Popover.Trigger className="UserList__more"> <Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - maxAvatars + 1} +{uniqueCollaboratorsArray.length - maxAvatars + 1}
</Popover.Trigger> </Popover.Trigger>
@@ -224,41 +215,43 @@ export const UserList = React.memo(
align="end" align="end"
sideOffset={10} sideOffset={10}
> >
<Island style={{ overflow: "hidden" }}> <Island padding={2}>
{uniqueCollaboratorsArray.length >= {uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && ( SHOW_COLLABORATORS_FILTER_AT && (
<div className="UserList__search-wrapper"> <QuickSearch
{searchIcon} placeholder={t("quickSearch.placeholder")}
<input onChange={setSearchTerm}
className="UserList__search" />
type="text"
placeholder={t("userList.search.placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
</div>
)} )}
<div className="dropdown-menu UserList__collaborators"> <ScrollableList
{filteredCollaborators.length === 0 && ( className={"dropdown-menu UserList__collaborators"}
<div className="UserList__collaborators__empty"> placeholder={t("userList.empty")}
{t("userList.search.empty")} >
</div> {/* The list checks for `Children.count()`, hence defensively returning empty list */}
)} {filteredCollaborators.length > 0
<div className="UserList__hint"> ? [
{t("userList.hint.text")} <div className="hint">{t("userList.hint.text")}</div>,
</div> filteredCollaborators.map((collaborator) =>
{filteredCollaborators.map((collaborator) => renderCollaborator({
renderCollaborator({ actionManager,
actionManager, collaborator,
collaborator, socketId: collaborator.socketId,
socketId: collaborator.socketId, withName: true,
withName: true, isBeingFollowed:
isBeingFollowed: collaborator.socketId === userToFollow, collaborator.socketId === userToFollow,
}), }),
)} ),
</div> ]
: []}
</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)",
}}
/>
</Island> </Island>
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
@@ -105,6 +105,7 @@ const getRelevantAppStateProps = (
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight, frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
}); });
const areEqual = ( const areEqual = (
@@ -4,7 +4,7 @@
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
top: 100%; top: 100%;
margin-top: 0.25rem; margin-top: 0.5rem;
&--mobile { &--mobile {
left: 0; left: 0;
@@ -35,21 +35,69 @@
.dropdown-menu-item-base { .dropdown-menu-item-base {
display: flex; display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem; column-gap: 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-on-surface); color: var(--color-on-surface);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
font-weight: normal; font-weight: 400;
font-family: inherit; 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 { .dropdown-menu-item {
height: 2rem;
margin: 1px;
padding: 0 0.5rem;
width: calc(100% - 2px);
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
align-items: center; align-items: center;
height: 2rem;
cursor: pointer; cursor: pointer;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
@@ -57,11 +105,6 @@
height: 2.25rem; height: 2.25rem;
} }
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&__text { &__text {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -83,6 +126,11 @@
} }
} }
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&:hover { &:hover {
background-color: var(--button-hover-bg); background-color: var(--button-hover-bg);
text-decoration: none; text-decoration: none;
@@ -1,37 +1,62 @@
import React from "react"; import React, { useEffect, useRef } from "react";
import { import {
getDropdownMenuItemClassName, getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick, useHandleDropdownMenuItemClick,
} from "./common"; } from "./common";
import MenuItemContent from "./DropdownMenuItemContent"; import MenuItemContent from "./DropdownMenuItemContent";
import { useExcalidrawAppState } from "../App";
import { THEME } from "../../constants";
import type { ValueOf } from "../../utility-types";
const DropdownMenuItem = ({ const DropdownMenuItem = ({
icon, icon,
onSelect, value,
order,
children, children,
shortcut, shortcut,
className, className,
hovered,
selected, selected,
textStyle,
onSelect,
onClick,
...rest ...rest
}: { }: {
icon?: JSX.Element; icon?: JSX.Element;
onSelect: (event: Event) => void; value?: string | number | undefined;
order?: number;
onSelect?: (event: Event) => void;
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
hovered?: boolean;
selected?: boolean; selected?: boolean;
textStyle?: React.CSSProperties;
className?: string; className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => { } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, 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]);
return ( return (
<button <button
{...rest} {...rest}
ref={ref}
value={value}
onClick={handleClick} onClick={handleClick}
type="button" className={getDropdownMenuItemClassName(className, selected, hovered)}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}
> >
<MenuItemContent icon={icon} shortcut={shortcut}> <MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children} {children}
</MenuItemContent> </MenuItemContent>
</button> </button>
@@ -39,24 +64,53 @@ const DropdownMenuItem = ({
}; };
DropdownMenuItem.displayName = "DropdownMenuItem"; DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropDownMenuItemBadgeType = {
GREEN: "green",
RED: "red",
BLUE: "blue",
} as const;
export const DropDownMenuItemBadge = ({ export const DropDownMenuItemBadge = ({
type = DropDownMenuItemBadgeType.BLUE,
children, children,
}: { }: {
type?: ValueOf<typeof DropDownMenuItemBadgeType>;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
return ( const { theme } = useExcalidrawAppState();
<div const style = {
style={{ display: "inline-flex",
display: "inline-flex", marginLeft: "auto",
marginLeft: "auto", padding: "2px 4px",
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, {
background: "var(--color-promo)", background: "var(--color-promo)",
color: "var(--color-surface-lowest)", color: "var(--color-surface-lowest)",
borderRadius: 6, });
fontSize: 9, }
fontFamily: "Cascadia, monospace",
}} return (
> <div className="DropDownMenuItemBadge" style={style}>
{children} {children}
</div> </div>
); );
@@ -1,19 +1,23 @@
import { useDevice } from "../App"; import { useDevice } from "../App";
const MenuItemContent = ({ const MenuItemContent = ({
textStyle,
icon, icon,
shortcut, shortcut,
children, children,
}: { }: {
icon?: JSX.Element; icon?: JSX.Element;
shortcut?: string; shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const device = useDevice(); const device = useDevice();
return ( return (
<> <>
<div className="dropdown-menu-item__icon">{icon}</div> {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div className="dropdown-menu-item__text">{children}</div> <div style={textStyle} className="dropdown-menu-item__text">
{children}
</div>
{shortcut && !device.editor.isMobile && ( {shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div> <div className="dropdown-menu-item__shortcut">{shortcut}</div>
)} )}
@@ -9,9 +9,11 @@ export const DropdownMenuContentPropsContext = React.createContext<{
export const getDropdownMenuItemClassName = ( export const getDropdownMenuItemClassName = (
className = "", className = "",
selected = false, selected = false,
hovered = false,
) => { ) => {
return `dropdown-menu-item dropdown-menu-item-base ${className} ${ return `dropdown-menu-item dropdown-menu-item-base ${className}
selected ? "dropdown-menu-item--selected" : "" ${selected ? "dropdown-menu-item--selected" : ""} ${
hovered ? "dropdown-menu-item--hovered" : ""
}`.trim(); }`.trim();
}; };
+21
View File
@@ -1438,6 +1438,27 @@ export const fontSizeIcon = createIcon(
tablerIconProps, 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( export const FontFamilyNormalIcon = createIcon(
<> <>
<g <g
@@ -109,7 +109,7 @@ Center.displayName = "Center";
const Logo = ({ children }: { children?: React.ReactNode }) => { const Logo = ({ children }: { children?: React.ReactNode }) => {
return ( return (
<div className="welcome-screen-center__logo virgil welcome-screen-decor"> <div className="welcome-screen-center__logo excalifont welcome-screen-decor">
{children || <ExcalidrawLogo withText />} {children || <ExcalidrawLogo withText />}
</div> </div>
); );
@@ -118,7 +118,7 @@ Logo.displayName = "Logo";
const Heading = ({ children }: { children: React.ReactNode }) => { const Heading = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="welcome-screen-center__heading welcome-screen-decor virgil"> <div className="welcome-screen-center__heading welcome-screen-decor excalifont">
{children} {children}
</div> </div>
); );
@@ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenMenuHintTunnel } = useTunnels(); const { WelcomeScreenMenuHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenMenuHintTunnel.In> <WelcomeScreenMenuHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu"> <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
{WelcomeScreenMenuArrow} {WelcomeScreenMenuArrow}
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.menuHint")} {children || t("welcomeScreen.defaults.menuHint")}
@@ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenToolbarHintTunnel } = useTunnels(); const { WelcomeScreenToolbarHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenToolbarHintTunnel.In> <WelcomeScreenToolbarHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar"> <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.toolbarHint")} {children || t("welcomeScreen.defaults.toolbarHint")}
</div> </div>
@@ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => {
const { WelcomeScreenHelpHintTunnel } = useTunnels(); const { WelcomeScreenHelpHintTunnel } = useTunnels();
return ( return (
<WelcomeScreenHelpHintTunnel.In> <WelcomeScreenHelpHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help"> <div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div>{children || t("welcomeScreen.defaults.helpHint")}</div> <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
{WelcomeScreenHelpArrow} {WelcomeScreenHelpArrow}
</div> </div>
@@ -1,6 +1,6 @@
.excalidraw { .excalidraw {
.virgil { .excalifont {
font-family: "Virgil"; font-family: "Excalifont";
} }
// WelcomeSreen common // WelcomeSreen common
+16 -4
View File
@@ -114,12 +114,24 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
}; };
// 1-based in case we ever do `if(element.fontFamily)` /**
* // 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
*/
export const FONT_FAMILY = { export const FONT_FAMILY = {
Virgil: 1, Virgil: 1,
Helvetica: 2, Helvetica: 2,
Cascadia: 3, Cascadia: 3,
Assistant: 4, // 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,
}; };
export const THEME = { export const THEME = {
@@ -147,7 +159,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1; export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}"; export const DEFAULT_VERSION = "{version}";
@@ -274,7 +286,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
+9 -12
View File
@@ -152,7 +152,7 @@ body.excalidraw-cursor-resize * {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-primary-color); color: var(--text-primary-color);
font-weight: normal; font-weight: 400;
display: block; display: block;
} }
@@ -227,14 +227,7 @@ body.excalidraw-cursor-resize * {
label, label,
button, button,
.zIndexButton { .zIndexButton {
@include outlineButtonStyles; @include outlineButtonIconStyles;
padding: 0;
svg {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
} }
} }
@@ -394,7 +387,7 @@ body.excalidraw-cursor-resize * {
.App-menu__left { .App-menu__left {
overflow-y: auto; overflow-y: auto;
padding: 0.75rem; padding: 0.75rem;
width: 202px; width: 200px;
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
@@ -585,7 +578,7 @@ body.excalidraw-cursor-resize * {
// use custom, minimalistic scrollbar // use custom, minimalistic scrollbar
// (doesn't work in Firefox) // (doesn't work in Firefox)
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 3px; width: 4px;
height: 3px; height: 3px;
} }
@@ -664,6 +657,10 @@ body.excalidraw-cursor-resize * {
--button-hover-bg: #363541; --button-hover-bg: #363541;
--button-bg: var(--color-surface-high); --button-bg: var(--color-surface-high);
} }
.buttonList {
padding: 0.25rem 0;
}
} }
.excalidraw__paragraph { .excalidraw__paragraph {
@@ -757,7 +754,7 @@ body.excalidraw-cursor-resize * {
padding: 1rem 1.6rem; padding: 1rem 1.6rem;
border-radius: 12px; border-radius: 12px;
color: #fff; color: #fff;
font-weight: bold; font-weight: 700;
letter-spacing: 0.6px; letter-spacing: 0.6px;
font-family: "Assistant"; font-family: "Assistant";
} }
+3
View File
@@ -151,6 +151,9 @@
--color-border-outline-variant: #c5c5d0; --color-border-outline-variant: #c5c5d0;
--color-surface-primary-container: #e0dfff; --color-surface-primary-container: #e0dfff;
--color-badge: #0b6513;
--background-color-badge: #d3ffd2;
&.theme--dark { &.theme--dark {
&.theme--dark-background-none { &.theme--dark-background-none {
background: none; background: none;
+11 -1
View File
@@ -124,6 +124,16 @@
} }
} }
@mixin outlineButtonIconStyles {
@include outlineButtonStyles;
padding: 0;
svg {
width: var(--default-icon-size);
height: var(--default-icon-size);
}
}
@mixin avatarStyles { @mixin avatarStyles {
width: var(--avatar-size, 1.5rem); width: var(--avatar-size, 1.5rem);
height: var(--avatar-size, 1.5rem); height: var(--avatar-size, 1.5rem);
@@ -135,7 +145,7 @@
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 800; font-weight: 700;
line-height: 1; line-height: 1;
color: var(--color-gray-90); color: var(--color-gray-90);
flex: 0 0 auto; flex: 0 0 auto;
@@ -239,7 +239,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -285,7 +285,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -386,7 +386,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": "id48", "containerId": "id48",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -487,7 +487,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"containerId": "id37", "containerId": "id37",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -662,7 +662,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": "id41", "containerId": "id41",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -708,7 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -754,7 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -1207,7 +1207,7 @@ exports[`Test Transform > should transform text element 1`] = `
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -1248,7 +1248,7 @@ exports[`Test Transform > should transform text element 2`] = `
"containerId": null, "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -1581,7 +1581,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "B", "containerId": "B",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
@@ -1624,7 +1624,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "A", "containerId": "A",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
@@ -1667,7 +1667,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Alice", "containerId": "Alice",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
@@ -1710,7 +1710,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob", "containerId": "Bob",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
@@ -1753,7 +1753,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_Alice", "containerId": "Bob_Alice",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -1794,7 +1794,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_B", "containerId": "Bob_B",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2043,7 +2043,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id25", "containerId": "id25",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2084,7 +2084,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id26", "containerId": "id26",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2125,7 +2125,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id27", "containerId": "id27",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2167,7 +2167,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id28", "containerId": "id28",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2431,7 +2431,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id13", "containerId": "id13",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2472,7 +2472,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id14", "containerId": "id14",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2514,7 +2514,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id15", "containerId": "id15",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2558,7 +2558,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id16", "containerId": "id16",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2600,7 +2600,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id17", "containerId": "id17",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
@@ -2643,7 +2643,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id18", "containerId": "id18",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 5,
"fontSize": 20, "fontSize": 20,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
+5 -1
View File
@@ -154,7 +154,11 @@ export const loadSceneOrLibraryFromBlob = async (
}, },
localAppState, localAppState,
localElements, localElements,
{ repairBindings: true, refreshDimensions: false }, {
repairBindings: true,
normalizeIndices: true,
refreshDimensions: false,
},
), ),
}; };
} else if (isValidLibrary(data)) { } else if (isValidLibrary(data)) {
+38 -32
View File
@@ -44,14 +44,11 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types"; import type { MarkOptional, Mutable } from "../utility-types";
import { import { detectLineHeight, getContainerElement } from "../element/textElement";
detectLineHeight,
getContainerElement,
getDefaultLineHeight,
} from "../element/textElement";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { getLineHeight } from "../fonts";
import { normalizeIndices, syncInvalidIndices } from "../fractionalIndex";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -206,7 +203,7 @@ const restoreElement = (
detectLineHeight(element) detectLineHeight(element)
: // no element height likely means programmatic use, so default : // no element height likely means programmatic use, so default
// to a fixed line height // to a fixed line height
getDefaultLineHeight(element.fontFamily)); getLineHeight(element.fontFamily));
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
@@ -408,36 +405,41 @@ export const restoreElements = (
elements: ImportedDataState["elements"], elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */ /** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, opts?:
| {
refreshDimensions?: boolean;
repairBindings?: boolean;
normalizeIndices?: boolean;
}
| undefined,
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
// used to detect duplicate top-level element ids // used to detect duplicate top-level element ids
const existingIds = new Set<string>(); const existingIds = new Set<string>();
const localElementsMap = localElements ? arrayToMap(localElements) : null; const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = syncInvalidIndices( const restoredElementsTemp = (elements || []).reduce((elements, element) => {
(elements || []).reduce((elements, element) => { // filtering out selection, which is legacy, no longer kept in elements,
// filtering out selection, which is legacy, no longer kept in elements, // and causing issues if retained
// and causing issues if retained if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
if (element.type !== "selection" && !isInvisiblySmallElement(element)) { let migratedElement: ExcalidrawElement | null = restoreElement(element);
let migratedElement: ExcalidrawElement | null = restoreElement(element); if (migratedElement) {
if (migratedElement) { const localElement = localElementsMap?.get(element.id);
const localElement = localElementsMap?.get(element.id); if (localElement && localElement.version > migratedElement.version) {
if (localElement && localElement.version > migratedElement.version) { migratedElement = bumpVersion(migratedElement, localElement.version);
migratedElement = bumpVersion(
migratedElement,
localElement.version,
);
}
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
} }
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
} }
return elements; }
}, [] as ExcalidrawElement[]), return elements;
); }, [] as ExcalidrawElement[]);
const restoredElements = opts?.normalizeIndices
? normalizeIndices(restoredElementsTemp)
: syncInvalidIndices(restoredElementsTemp);
if (!opts?.repairBindings) { if (!opts?.repairBindings) {
return restoredElements; return restoredElements;
@@ -604,7 +606,11 @@ export const restore = (
*/ */
localAppState: Partial<AppState> | null | undefined, localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean }, elementsConfig?: {
refreshDimensions?: boolean;
repairBindings?: boolean;
normalizeIndices?: boolean;
},
): RestoredDataState => { ): RestoredDataState => {
return { return {
elements: restoreElements(data?.elements, localElements, elementsConfig), elements: restoreElements(data?.elements, localElements, elementsConfig),
+3 -7
View File
@@ -18,11 +18,7 @@ import {
newMagicFrameElement, newMagicFrameElement,
newTextElement, newTextElement,
} from "../element/newElement"; } from "../element/newElement";
import { import { measureText, normalizeText } from "../element/textElement";
getDefaultLineHeight,
measureText,
normalizeText,
} from "../element/textElement";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -54,6 +50,7 @@ import {
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { randomId } from "../random"; import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
@@ -568,8 +565,7 @@ export const convertToExcalidrawElements = (
case "text": { case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE; const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
element?.lineHeight || getDefaultLineHeight(fontFamily);
const text = element.text ?? ""; const text = element.text ?? "";
const normalizedText = normalizeText(text); const normalizedText = normalizeText(text);
const metrics = measureText( const metrics = measureText(
+67 -48
View File
@@ -25,7 +25,7 @@ import type {
} from "./types"; } from "./types";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import type { AppClassProperties, AppState, Point } from "../types"; import type { AppState, Point } from "../types";
import { isPointOnShape } from "../../utils/collision"; import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene"; import { getElementAtPosition } from "../scene";
import { import {
@@ -43,6 +43,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@@ -179,9 +180,8 @@ const bindOrUnbindLinearElementEdge = (
const getOriginalBindingIfStillCloseOfLinearElementEdge = ( const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end", edge: "start" | "end",
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawElement> | null => { ): NonDeleted<ExcalidrawElement> | null => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId = const elementId =
edge === "start" edge === "start"
@@ -189,7 +189,10 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
: linearElement.endBinding?.elementId; : linearElement.endBinding?.elementId;
if (elementId) { if (elementId) {
const element = elementsMap.get(elementId); const element = elementsMap.get(elementId);
if (isBindableElement(element) && bindingBorderTest(element, coors, app)) { if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap)
) {
return element; return element;
} }
} }
@@ -199,13 +202,13 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => ["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge( getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement, linearElement,
edge as "start" | "end", edge as "start" | "end",
app, elementsMap,
), ),
); );
@@ -213,7 +216,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[], draggingPoints: readonly number[],
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0; const startIdx = 0;
const endIdx = selectedElement.points.length - 1; const endIdx = selectedElement.points.length - 1;
@@ -221,37 +224,57 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged const start = startDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "start", app) ? getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
)
: null // If binding is disabled and start is dragged, break all binds : null // If binding is disabled and start is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind : // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(selectedElement, "start", app); getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
);
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "end", app) ? getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
)
: null // If binding is disabled and end is dragged, break all binds : null // If binding is disabled and end is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind : // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(selectedElement, "end", app); getElligibleElementForBindingElement(selectedElement, "end", elementsMap);
return [start, end]; return [start, end];
}; };
const getBindingStrategyForDraggingArrowOrJoints = ( const getBindingStrategyForDraggingArrowOrJoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
isBindingEnabled: boolean, isBindingEnabled: boolean,
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement, selectedElement,
app, elementsMap,
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "start", app) ? getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
)
: null : null
: null; : null;
const end = endIsClose const end = endIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "end", app) ? getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
)
: null : null
: null; : null;
@@ -260,7 +283,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
export const bindOrUnbindLinearElements = ( export const bindOrUnbindLinearElements = (
selectedElements: NonDeleted<ExcalidrawLinearElement>[], selectedElements: NonDeleted<ExcalidrawLinearElement>[],
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[] | null, draggingPoints: readonly number[] | null,
): void => { ): void => {
@@ -271,27 +294,22 @@ export const bindOrUnbindLinearElements = (
selectedElement, selectedElement,
isBindingEnabled, isBindingEnabled,
draggingPoints ?? [], draggingPoints ?? [],
app, elementsMap,
) )
: // The arrow itself (the shaft) or the inner joins are dragged : // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints( getBindingStrategyForDraggingArrowOrJoints(
selectedElement, selectedElement,
app, elementsMap,
isBindingEnabled, isBindingEnabled,
); );
bindOrUnbindLinearElement( bindOrUnbindLinearElement(selectedElement, start, end, elementsMap);
selectedElement,
start,
end,
app.scene.getNonDeletedElementsMap(),
);
}); });
}; };
export const getSuggestedBindingsForArrows = ( export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[], selectedElements: NonDeleted<ExcalidrawElement>[],
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): SuggestedBinding[] => { ): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large // HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) { if (selectedElements.length > 50) {
@@ -302,7 +320,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements selectedElements
.filter(isLinearElement) .filter(isLinearElement)
.flatMap((element) => .flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, app), getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
) )
.filter( .filter(
(element): element is NonDeleted<ExcalidrawBindableElement> => (element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -324,17 +342,20 @@ export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState, appState: AppState,
pointerCoords: { x: number; y: number }, pointerCoords: { x: number; y: number },
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): void => { ): void => {
if (appState.startBoundElement != null) { if (appState.startBoundElement != null) {
bindLinearElement( bindLinearElement(
linearElement, linearElement,
appState.startBoundElement, appState.startBoundElement,
"start", "start",
app.scene.getNonDeletedElementsMap(), elementsMap,
); );
} }
const hoveredElement = getHoveredElementForBinding(pointerCoords, app); const hoveredElement = getHoveredElementForBinding(
pointerCoords,
elementsMap,
);
if ( if (
hoveredElement != null && hoveredElement != null &&
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
@@ -343,12 +364,7 @@ export const maybeBindLinearElement = (
"end", "end",
) )
) { ) {
bindLinearElement( bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
linearElement,
hoveredElement,
"end",
app.scene.getNonDeletedElementsMap(),
);
} }
}; };
@@ -432,13 +448,13 @@ export const getHoveredElementForBinding = (
x: number; x: number;
y: number; y: number;
}, },
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition( const hoveredElement = getElementAtPosition(
app.scene.getNonDeletedElements(), [...elementsMap].map(([_, value]) => value),
(element) => (element) =>
isBindableElement(element, false) && isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords, app), bindingBorderTest(element, pointerCoords, elementsMap),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
@@ -662,15 +678,11 @@ const maybeCalculateNewGapWhenScaling = (
const getElligibleElementForBindingElement = ( const getElligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
getLinearElementEdgeCoors( getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
linearElement, elementsMap,
startOrEnd,
app.scene.getNonDeletedElementsMap(),
),
app,
); );
}; };
@@ -707,6 +719,9 @@ export const fixBindingsAfterDuplication = (
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set(); const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set(); const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld"; const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
const duplicateIdToOldId = new Map(
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
);
oldElements.forEach((oldElement) => { oldElements.forEach((oldElement) => {
const { boundElements } = oldElement; const { boundElements } = oldElement;
if (boundElements != null && boundElements.length > 0) { if (boundElements != null && boundElements.length > 0) {
@@ -756,7 +771,11 @@ export const fixBindingsAfterDuplication = (
sceneElements sceneElements
.filter(({ id }) => allBindableElementIds.has(id)) .filter(({ id }) => allBindableElementIds.has(id))
.forEach((bindableElement) => { .forEach((bindableElement) => {
const { boundElements } = bindableElement; const oldElementId = duplicateIdToOldId.get(bindableElement.id);
const { boundElements } = sceneElements.find(
({ id }) => id === oldElementId,
)!;
if (boundElements != null && boundElements.length > 0) { if (boundElements != null && boundElements.length > 0) {
mutateElement(bindableElement, { mutateElement(bindableElement, {
boundElements: boundElements.map((boundElement) => boundElements: boundElements.map((boundElement) =>
@@ -827,10 +846,10 @@ const newBoundElements = (
const bindingBorderTest = ( const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>, element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number }, { x, y }: { x: number; y: number },
app: AppClassProperties, elementsMap: ElementsMap,
): boolean => { ): boolean => {
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const shape = app.getElementShape(element); const shape = getElementShape(element, elementsMap);
return isPointOnShape([x, y], shape, threshold); return isPointOnShape([x, y], shape, threshold);
}; };
+9 -2
View File
@@ -18,6 +18,7 @@ import {
isImageElement, isImageElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getBoundTextShape } from "../shapes";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (element.type === "arrow") {
@@ -97,6 +98,12 @@ export const hitElementBoundingBoxOnly = (
) => { ) => {
return ( return (
!hitElementItself(hitArgs) && !hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap) hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
); );
}; };
@@ -105,6 +112,6 @@ export const hitElementBoundText = (
x: number, x: number,
y: number, y: number,
textShape: GeometricShape | null, textShape: GeometricShape | null,
) => { ): boolean => {
return textShape && isPointInShape([x, y], textShape); return !!textShape && isPointInShape([x, y], textShape);
}; };
@@ -381,7 +381,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
), ),
), ),
app, elementsMap,
) )
: null; : null;
@@ -715,7 +715,10 @@ export class LinearElementEditor {
}, },
selectedPointsIndices: [element.points.length - 1], selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null, lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(scenePointer, app), endBindingElement: getHoveredElementForBinding(
scenePointer,
elementsMap,
),
}; };
ret.didAddPoint = true; ret.didAddPoint = true;
@@ -1165,7 +1168,7 @@ export class LinearElementEditor {
const nextPoints = points.map((point, idx) => { const nextPoints = points.map((point, idx) => {
const selectedPointData = targetPoints.find((p) => p.index === idx); const selectedPointData = targetPoints.find((p) => p.index === idx);
if (selectedPointData) { if (selectedPointData) {
if (selectedOriginPoint) { if (selectedPointData.index === 0) {
return point; return point;
} }
@@ -1174,7 +1177,10 @@ export class LinearElementEditor {
const deltaY = const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1]; selectedPointData.point[1] - points[selectedPointData.index][1];
return [point[0] + deltaX, point[1] + deltaY] as const; return [
point[0] + deltaX - offsetX,
point[1] + deltaY - offsetY,
] as const;
} }
return offsetX || offsetY return offsetX || offsetY
? ([point[0] - offsetX, point[1] - offsetY] as const) ? ([point[0] - offsetX, point[1] - offsetY] as const)
+3 -3
View File
@@ -36,7 +36,6 @@ import {
normalizeText, normalizeText,
wrapText, wrapText,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
import { import {
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
@@ -47,6 +46,7 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types"; import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -228,7 +228,7 @@ export const newTextElement = (
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily); const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureText( const metrics = measureText(
text, text,
@@ -514,7 +514,7 @@ export const regenerateId = (
if ( if (
window.h?.app window.h?.app
?.getSceneElementsIncludingDeleted() ?.getSceneElementsIncludingDeleted()
.find((el) => el.id === nextId) .find((el: ExcalidrawElement) => el.id === nextId)
) { ) {
nextId += "_copy"; nextId += "_copy";
} }
@@ -1,4 +1,5 @@
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { getLineHeight } from "../fonts";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
@@ -8,7 +9,6 @@ import {
wrapText, wrapText,
detectLineHeight, detectLineHeight,
getLineHeightInPx, getLineHeightInPx,
getDefaultLineHeight,
parseTokens, parseTokens,
} from "./textElement"; } from "./textElement";
import type { ExcalidrawTextElementWithContainer, FontString } from "./types"; import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
@@ -418,15 +418,15 @@ describe("Test getLineHeightInPx", () => {
describe("Test getDefaultLineHeight", () => { describe("Test getDefaultLineHeight", () => {
it("should return line height using default font family when not passed", () => { it("should return line height using default font family when not passed", () => {
//@ts-ignore //@ts-ignore
expect(getDefaultLineHeight()).toBe(1.25); expect(getLineHeight()).toBe(1.25);
}); });
it("should return line height using default font family for unknown font", () => { it("should return line height using default font family for unknown font", () => {
const UNKNOWN_FONT = 5; const UNKNOWN_FONT = 5;
expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25); expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
}); });
it("should return correct line height", () => { it("should return correct line height", () => {
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
}); });
}); });
+84 -143
View File
@@ -6,7 +6,6 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontFamilyValues,
FontString, FontString,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@@ -17,7 +16,6 @@ import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY,
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
@@ -30,7 +28,7 @@ import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
import type { ExtractSetType, MakeBrand } from "../utility-types"; import type { ExtractSetType } from "../utility-types";
export const normalizeText = (text: string) => { export const normalizeText = (text: string) => {
return ( return (
@@ -321,24 +319,6 @@ export const getLineHeightInPx = (
return fontSize * lineHeight; return fontSize * lineHeight;
}; };
/**
* Calculates vertical offset for a text with alphabetic baseline.
*/
export const getVerticalOffset = (
fontFamily: ExcalidrawTextElement["fontFamily"],
fontSize: ExcalidrawTextElement["fontSize"],
lineHeightPx: number,
) => {
const { unitsPerEm, ascender, descender } =
FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica];
const fontSizeEm = fontSize / unitsPerEm;
const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
const verticalOffset = fontSizeEm * ascender + lineGap;
return verticalOffset;
};
// FIXME rename to getApproxMinContainerHeight // FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = ( export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"], fontSize: ExcalidrawTextElement["fontSize"],
@@ -349,29 +329,72 @@ export const getApproxMinLineHeight = (
let canvas: HTMLCanvasElement | undefined; let canvas: HTMLCanvasElement | undefined;
const getLineWidth = (text: string, font: FontString) => { /**
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* Everything else should be based on the actual bounding box width.
*
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
*/
const getLineWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
if (!canvas) { if (!canvas) {
canvas = document.createElement("canvas"); canvas = document.createElement("canvas");
} }
const canvas2dContext = canvas.getContext("2d")!; const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font; canvas2dContext.font = font;
const width = canvas2dContext.measureText(text).width; const metrics = canvas2dContext.measureText(text);
const advanceWidth = metrics.width;
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
if (
!forceAdvanceWidth &&
window.TextMetrics &&
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
"actualBoundingBoxRight" in window.TextMetrics.prototype
) {
// could be negative, therefore getting the absolute value
const actualWidth =
Math.abs(metrics.actualBoundingBoxLeft) +
Math.abs(metrics.actualBoundingBoxRight);
// fallback to advance width if the actual width is zero, i.e. on text editing start
// or when actual width does not respect whitespace chars, i.e. spaces
// otherwise actual width should always be bigger
return Math.max(actualWidth, advanceWidth);
}
// since in test env the canvas measureText algo // since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of // doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px // characters hence we assume that each letteris 10px
if (isTestEnv()) { if (isTestEnv()) {
return width * 10; return advanceWidth * 10;
} }
return width;
return advanceWidth;
}; };
export const getTextWidth = (text: string, font: FontString) => { export const getTextWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
) => {
const lines = splitIntoLines(text); const lines = splitIntoLines(text);
let width = 0; let width = 0;
lines.forEach((line) => { lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font)); width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
}); });
return width; return width;
}; };
@@ -402,7 +425,11 @@ export const parseTokens = (text: string) => {
return words.join(" ").split(" "); return words.join(" ").split(" ");
}; };
export const wrapText = (text: string, font: FontString, maxWidth: number) => { export const wrapText = (
text: string,
font: FontString,
maxWidth: number,
): string => {
// if maxWidth is not finite or NaN which can happen in case of bugs in // if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up // computation, we need to make sure we don't continue as we'll end up
// in an infinite loop // in an infinite loop
@@ -412,7 +439,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = []; const lines: Array<string> = [];
const originalLines = text.split("\n"); const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font); const spaceAdvanceWidth = getLineWidth(" ", font, true);
let currentLine = ""; let currentLine = "";
let currentLineWidthTillNow = 0; let currentLineWidthTillNow = 0;
@@ -427,13 +454,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
currentLine = ""; currentLine = "";
currentLineWidthTillNow = 0; currentLineWidthTillNow = 0;
}; };
originalLines.forEach((originalLine) => {
const currentLineWidth = getTextWidth(originalLine, font); for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true);
// Push the line if its <= maxWidth // Push the line if its <= maxWidth
if (currentLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push(originalLine); lines.push(originalLine);
return; // continue continue;
} }
const words = parseTokens(originalLine); const words = parseTokens(originalLine);
@@ -442,7 +470,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
let index = 0; let index = 0;
while (index < words.length) { while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font); const currentWordWidth = getLineWidth(words[index], font, true);
// This will only happen when single word takes entire width // This will only happen when single word takes entire width
if (currentWordWidth === maxWidth) { if (currentWordWidth === maxWidth) {
@@ -454,7 +482,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
else if (currentWordWidth > maxWidth) { else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width // push current line since the current word exceeds the max width
// so will be appended in next line // so will be appended in next line
push(currentLine); push(currentLine);
resetParams(); resetParams();
@@ -463,20 +490,26 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const currentChar = String.fromCodePoint( const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!, words[index].codePointAt(0)!,
); );
const width = charWidth.calculate(currentChar, font);
currentLineWidthTillNow += width; const line = currentLine + currentChar;
// use advance width instead of the actual width as it's closest to the browser wapping algo
// use width of the whole line instead of calculating individual chars to accomodate for kerning
const lineAdvanceWidth = getLineWidth(line, font, true);
const charAdvanceWidth = charWidth.calculate(currentChar, font);
currentLineWidthTillNow = lineAdvanceWidth;
words[index] = words[index].slice(currentChar.length); words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) { if (currentLineWidthTillNow >= maxWidth) {
push(currentLine); push(currentLine);
currentLine = currentChar; currentLine = currentChar;
currentLineWidthTillNow = width; currentLineWidthTillNow = charAdvanceWidth;
} else { } else {
currentLine += currentChar; currentLine = line;
} }
} }
// push current line if appending space exceeds max width // push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) { if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
push(currentLine); push(currentLine);
resetParams(); resetParams();
// space needs to be appended before next word // space needs to be appended before next word
@@ -485,14 +518,18 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// with css word-wrap // with css word-wrap
} else if (!currentLine.endsWith("-")) { } else if (!currentLine.endsWith("-")) {
currentLine += " "; currentLine += " ";
currentLineWidthTillNow += spaceWidth; currentLineWidthTillNow += spaceAdvanceWidth;
} }
index++; index++;
} else { } else {
// Start appending words in a line till max width reached // Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) { while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index]; const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font); currentLineWidthTillNow = getLineWidth(
currentLine + word,
font,
true,
);
if (currentLineWidthTillNow > maxWidth) { if (currentLineWidthTillNow > maxWidth) {
push(currentLine); push(currentLine);
@@ -512,7 +549,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
} }
// Push the word if appending space exceeds max width // Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) { if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
if (shouldAppendSpace) { if (shouldAppendSpace) {
lines.push(currentLine.slice(0, -1)); lines.push(currentLine.slice(0, -1));
} else { } else {
@@ -524,12 +561,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
} }
} }
} }
if (currentLine.slice(-1) === " ") { if (currentLine.slice(-1) === " ") {
// only remove last trailing space which we have added when joining words // only remove last trailing space which we have added when joining words
currentLine = currentLine.slice(0, -1); currentLine = currentLine.slice(0, -1);
push(currentLine); push(currentLine);
} }
}); }
return lines.join("\n"); return lines.join("\n");
}; };
@@ -542,7 +581,7 @@ export const charWidth = (() => {
cachedCharWidth[font] = []; cachedCharWidth[font] = [];
} }
if (!cachedCharWidth[font][ascii]) { if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font); const width = getLineWidth(char, font, true);
cachedCharWidth[font][ascii] = width; cachedCharWidth[font][ascii] = width;
} }
@@ -594,34 +633,9 @@ export const getMaxCharWidth = (font: FontString) => {
return Math.max(...cacheWithOutEmpty); return Math.max(...cacheWithOutEmpty);
}; };
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
// Generally lower case is used so converting to lower case
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
const batchLength = 6;
let index = 0;
let widthTillNow = 0;
let str = "";
while (widthTillNow <= width) {
const batch = dummyText.substr(index, index + batchLength);
str += batch;
widthTillNow += getLineWidth(str, font);
if (index === dummyText.length - 1) {
index = 0;
}
index = index + batchLength;
}
while (widthTillNow > width) {
str = str.substr(0, str.length - 1);
widthTillNow = getLineWidth(str, font);
}
return str.length;
};
export const getBoundTextElementId = (container: ExcalidrawElement | null) => { export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.length return container?.boundElements?.length
? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id || ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
null
: null; : null;
}; };
@@ -866,79 +880,6 @@ export const isMeasureTextSupported = () => {
return width > 0; return width > 0;
}; };
/**
* Unitless line height
*
* In previous versions we used `normal` line height, which browsers interpret
* differently, and based on font-family and font-size.
*
* To make line heights consistent across browsers we hardcode the values for
* each of our fonts based on most common average line-heights.
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
* where the values come from.
*/
const DEFAULT_LINE_HEIGHT = {
// ~1.25 is the average for Virgil in WebKit and Blink.
// Gecko (FF) uses ~1.28.
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
// ~1.15 is the average for Helvetica in WebKit and Blink.
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
};
/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */
type sTypoAscender = number & MakeBrand<"sTypoAscender">;
/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */
type sTypoDescender = number & MakeBrand<"sTypoDescender">;
/** head.unitsPerEm, usually either 1000 or 2048 */
type unitsPerEm = number & MakeBrand<"unitsPerEm">;
/**
* Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
* For custom fonts, read these metrics from OS/2 table and extend this object.
*
* WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first.
*/
export const FONT_METRICS: Record<
number,
{
unitsPerEm: number;
ascender: sTypoAscender;
descender: sTypoDescender;
}
> = {
[FONT_FAMILY.Virgil]: {
unitsPerEm: 1000 as unitsPerEm,
ascender: 886 as sTypoAscender,
descender: -374 as sTypoDescender,
},
[FONT_FAMILY.Helvetica]: {
unitsPerEm: 2048 as unitsPerEm,
ascender: 1577 as sTypoAscender,
descender: -471 as sTypoDescender,
},
[FONT_FAMILY.Cascadia]: {
unitsPerEm: 2048 as unitsPerEm,
ascender: 1977 as sTypoAscender,
descender: -480 as sTypoDescender,
},
[FONT_FAMILY.Assistant]: {
unitsPerEm: 1000 as unitsPerEm,
ascender: 1021 as sTypoAscender,
descender: -287 as sTypoDescender,
},
};
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
if (fontFamily in DEFAULT_LINE_HEIGHT) {
return DEFAULT_LINE_HEIGHT[fontFamily];
}
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
};
export const getMinTextElementWidth = ( export const getMinTextElementWidth = (
font: FontString, font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"], lineHeight: ExcalidrawTextElement["lineHeight"],
@@ -916,13 +916,13 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil); expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
fireEvent.click(screen.getByTitle(/code/i)); fireEvent.click(screen.getByTitle(/code/i));
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia); ).toEqual(FONT_FAMILY["Comic Shanns"]);
//undo //undo
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
@@ -930,7 +930,7 @@ describe("textWysiwyg", () => {
}); });
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Virgil); ).toEqual(FONT_FAMILY.Excalifont);
//redo //redo
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
@@ -938,7 +938,7 @@ describe("textWysiwyg", () => {
}); });
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia); ).toEqual(FONT_FAMILY["Comic Shanns"]);
}); });
it("should wrap text and vertcially center align once text submitted", async () => { it("should wrap text and vertcially center align once text submitted", async () => {
@@ -1330,14 +1330,14 @@ describe("textWysiwyg", () => {
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia); ).toEqual(FONT_FAMILY["Comic Shanns"]);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
fireEvent.click(screen.getByTitle(/Very large/i)); fireEvent.click(screen.getByTitle(/Very large/i));
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36); ).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100);
}); });
it("should update line height when font family updated", async () => { it("should update line height when font family updated", async () => {
@@ -1357,18 +1357,18 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle(/code/i)); fireEvent.click(screen.getByTitle(/code/i));
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia); ).toEqual(FONT_FAMILY["Comic Shanns"]);
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.2); ).toEqual(1.25);
fireEvent.click(screen.getByTitle(/normal/i)); fireEvent.click(screen.getByTitle(/normal/i));
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Helvetica); ).toEqual(FONT_FAMILY.Nunito);
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.15); ).toEqual(1.35);
}); });
describe("should align correctly", () => { describe("should align correctly", () => {
+49 -61
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES } from "../constants"; import { CLASSES, isSafari } from "../constants";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -132,10 +132,15 @@ export const textWysiwyg = ({
updatedTextElement, updatedTextElement,
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
let width = updatedTextElement.width;
// set to element height by default since that's
// what is going to be used for unbounded text
let height = updatedTextElement.height;
let maxWidth = updatedTextElement.width; let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height; let maxHeight = updatedTextElement.height;
let textElementWidth = updatedTextElement.width;
const textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) { if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) { if (isArrowElement(container)) {
@@ -177,9 +182,9 @@ export const textWysiwyg = ({
); );
// autogrow container height if text exceeds // autogrow container height if text exceeds
if (!isArrowElement(container) && textElementHeight > maxHeight) { if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight = computeContainerDimensionForBoundText( const targetContainerHeight = computeContainerDimensionForBoundText(
textElementHeight, height,
container.type, container.type,
); );
@@ -190,10 +195,10 @@ export const textWysiwyg = ({
// is reached when text is removed // is reached when text is removed
!isArrowElement(container) && !isArrowElement(container) &&
container.height > originalContainerData.height && container.height > originalContainerData.height &&
textElementHeight < maxHeight height < maxHeight
) { ) {
const targetContainerHeight = computeContainerDimensionForBoundText( const targetContainerHeight = computeContainerDimensionForBoundText(
textElementHeight, height,
container.type, container.type,
); );
mutateElement(container, { height: targetContainerHeight }); mutateElement(container, { height: targetContainerHeight });
@@ -226,30 +231,41 @@ export const textWysiwyg = ({
if (!container) { if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth); width = Math.min(width, maxWidth);
} else { } else {
textElementWidth += 0.5; width += 0.5;
} }
// add 5% buffer otherwise it causes wysiwyg to jump
height *= 1.05;
const font = getFontString(updatedTextElement);
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
const padding = !isSafari
? Math.ceil(updatedTextElement.fontSize / 2)
: 0;
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value; (appState.height - viewportY) / appState.zoom.value;
Object.assign(editable.style, { Object.assign(editable.style, {
font: getFontString(updatedTextElement), font,
// must be defined *after* font ¯\_(ツ)_/¯ // must be defined *after* font ¯\_(ツ)_/¯
lineHeight: updatedTextElement.lineHeight, lineHeight: updatedTextElement.lineHeight,
width: `${textElementWidth}px`, width: `${width}px`,
height: `${textElementHeight}px`, height: `${height}px`,
left: `${viewportX}px`, left: `${viewportX - padding}px`,
top: `${viewportY}px`, top: `${viewportY}px`,
transform: getTransform( transform: getTransform(
textElementWidth, width,
textElementHeight, height,
getTextElementAngle(updatedTextElement, container), getTextElementAngle(updatedTextElement, container),
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
), ),
padding: `0 ${padding}px`,
textAlign, textAlign,
verticalAlign, verticalAlign,
color: updatedTextElement.strokeColor, color: updatedTextElement.strokeColor,
@@ -290,7 +306,6 @@ export const textWysiwyg = ({
minHeight: "1em", minHeight: "1em",
backfaceVisibility: "hidden", backfaceVisibility: "hidden",
margin: 0, margin: 0,
padding: 0,
border: 0, border: 0,
outline: 0, outline: 0,
resize: "none", resize: "none",
@@ -336,7 +351,7 @@ export const textWysiwyg = ({
font, font,
getBoundTextMaxWidth(container, boundTextElement), getBoundTextMaxWidth(container, boundTextElement),
); );
const width = getTextWidth(wrappedText, font); const width = getTextWidth(wrappedText, font, true);
editable.style.width = `${width}px`; editable.style.width = `${width}px`;
} }
}; };
@@ -485,8 +500,10 @@ export const textWysiwyg = ({
}; };
const stopEvent = (event: Event) => { const stopEvent = (event: Event) => {
event.preventDefault(); if (event.target instanceof HTMLCanvasElement) {
event.stopPropagation(); event.preventDefault();
event.stopPropagation();
}
}; };
// using a state variable instead of passing it to the handleSubmit callback // using a state variable instead of passing it to the handleSubmit callback
@@ -579,46 +596,15 @@ export const textWysiwyg = ({
// in that same tick. // in that same tick.
const target = event?.target; const target = event?.target;
const isTargetPickerTrigger = const isPropertiesTrigger =
target instanceof HTMLElement && target instanceof HTMLElement &&
target.classList.contains("active-color"); target.classList.contains("properties-trigger");
setTimeout(() => { setTimeout(() => {
editable.onblur = handleSubmit; editable.onblur = handleSubmit;
if (isTargetPickerTrigger) {
const callback = (
mutationList: MutationRecord[],
observer: MutationObserver,
) => {
const radixIsRemoved = mutationList.find(
(mutation) =>
mutation.removedNodes.length > 0 &&
(mutation.removedNodes[0] as HTMLElement).dataset
?.radixPopperContentWrapper !== undefined,
);
if (radixIsRemoved) {
// should work without this in theory
// and i think it does actually but radix probably somewhere,
// somehow sets the focus elsewhere
setTimeout(() => {
editable.focus();
});
observer.disconnect();
}
};
const observer = new MutationObserver(callback);
observer.observe(document.querySelector(".excalidraw-container")!, {
childList: true,
});
}
// case: clicking on the same property → no change → no update → no focus // case: clicking on the same property → no change → no update → no focus
if (!isTargetPickerTrigger) { if (!isPropertiesTrigger) {
editable.focus(); editable.focus();
} }
}); });
@@ -626,16 +612,18 @@ export const textWysiwyg = ({
// prevent blur when changing properties from the menu // prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => { const onPointerDown = (event: MouseEvent) => {
const isTargetPickerTrigger = const target = event?.target;
event.target instanceof HTMLElement &&
event.target.classList.contains("active-color"); const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
if ( if (
((event.target instanceof HTMLElement || ((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) && event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) && event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target)) || !isWritableElement(event.target)) ||
isTargetPickerTrigger isPropertiesTrigger
) { ) {
editable.onblur = null; editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent); window.addEventListener("pointerup", bindBlurEvent);
@@ -644,7 +632,7 @@ export const textWysiwyg = ({
window.addEventListener("blur", handleSubmit); window.addEventListener("blur", handleSubmit);
} else if ( } else if (
event.target instanceof HTMLElement && event.target instanceof HTMLElement &&
!event.target.contains(editable) && event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF // Vitest simply ignores stopPropagation, capture-mode, or rAF
// so without introducing crazier hacks, nothing we can do // so without introducing crazier hacks, nothing we can do
!isTestEnv() !isTestEnv()
@@ -664,10 +652,10 @@ export const textWysiwyg = ({
// handle updates of textElement properties of editing element // handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => { const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
updateWysiwygStyle(); updateWysiwygStyle();
const isColorPickerActive = !!document.activeElement?.closest( const isPopupOpened = !!document.activeElement?.closest(
".color-picker-content", ".properties-content",
); );
if (!isColorPickerActive) { if (!isPopupOpened) {
editable.focus(); editable.focus();
} }
}); });
@@ -0,0 +1,78 @@
import { stringToBase64, toByteString } from "../data/encode";
export interface Font {
url: URL;
fontFace: FontFace;
getContent(): Promise<string>;
}
export const UNPKG_PROD_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
}@${import.meta.env.PKG_VERSION}/dist/prod/`;
export class ExcalidrawFont implements Font {
public readonly url: URL;
public readonly fontFace: FontFace;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
const assetUrl: string = uri.replace(/^\/+/, "");
let baseUrl: string | undefined = undefined;
// fallback to unpkg to form a valid URL in case of a passed relative assetUrl
let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL;
// in case user passed a root-relative url (~absolute path),
// like "/" or "/some/path", or relative (starts with "./"),
// prepend it with `location.origin`
if (/^\.?\//.test(baseUrlBuilder)) {
baseUrlBuilder = new URL(
baseUrlBuilder.replace(/^\.?\/+/, ""),
window?.location?.origin,
).toString();
}
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
baseUrl = `${baseUrlBuilder.replace(/\/+$/, "")}/`;
this.url = new URL(assetUrl, baseUrl);
this.fontFace = new FontFace(family, `url(${this.url})`, {
display: "swap",
style: "normal",
weight: "400",
...descriptors,
});
}
/**
* Fetches woff2 content based on the registered url (browser).
*
* Use dataurl outside the browser environment.
*/
public async getContent(): Promise<string> {
if (this.url.protocol === "data:") {
// it's dataurl, the font is inlined as base64, no need to fetch
return this.url.toString();
}
const response = await fetch(this.url, {
headers: {
Accept: "font/woff2",
},
});
if (!response.ok) {
console.error(
`Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`,
response,
);
}
const mimeType = await response.headers.get("Content-Type");
const buffer = await response.arrayBuffer();
return `data:${mimeType};base64,${await stringToBase64(
await toByteString(buffer),
true,
)}`;
}
}

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