Merge branch 'master' into feat/astar

This commit is contained in:
Mark Tolmacs 2024-08-02 12:44:56 +02:00
commit 7f5a9366ad
No known key found for this signature in database
158 changed files with 24111 additions and 4300 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,8 @@
"version": "0.1.0",
"private": true,
"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",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,7 +36,8 @@
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"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"
}
}

View File

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

View File

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

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 font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)

View File

@ -4,10 +4,7 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
} from "../element/types";
import type { ExcalidrawArrowElement, ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
import type { StoreActionType } from "../store";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
@ -38,9 +41,6 @@ import {
FontSizeExtraLargeIcon,
EdgeSharpIcon,
EdgeRoundIcon,
FreedrawIcon,
FontFamilyNormalIcon,
FontFamilyCodeIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
@ -50,11 +50,12 @@ import {
ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon,
fontSizeIcon,
arrowUpRightIcon,
arrowCurveRight,
arrowGuideIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons";
import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
@ -68,10 +69,7 @@ import {
redrawTextBoundingBox,
} from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { getBoundTextElement } from "../element/textElement";
import {
isArrowElement,
isBoundToContainer,
@ -81,6 +79,7 @@ import {
} from "../element/typeChecks";
import type {
Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
@ -99,15 +98,23 @@ import {
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey, tupleToCoors } from "../utils";
import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { register } from "./register";
import { StoreAction } from "../store";
import { getArrowLocalFixedPoints, mutateElbowArrow } from "../element/routing";
import { Fonts, getLineHeight } from "../fonts";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -740,104 +747,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({
name: "changeFontFamily",
label: "labels.fontFamily",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
value as ChangeFontFamilyData;
if (resetAll) {
const nextElements = changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
fontFamily: value,
lineHeight: getDefaultLineHeight(value),
},
);
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
(element) => {
const cachedElement = cachedElements?.get(element.id);
if (cachedElement) {
const newElement = newElementWith(element, {
...cachedElement,
});
return newElement;
}
return oldElement;
return element;
},
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,
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 }) => {
const options: {
value: FontFamilyValues;
text: string;
icon: JSX.Element;
testId: string;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: FreedrawIcon,
testId: "font-family-virgil",
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: FontFamilyNormalIcon,
testId: "font-family-normal",
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: FontFamilyCodeIcon,
testId: "font-family-code",
},
];
PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
const isUnmounted = useRef(true);
const selectedFontFamily = useMemo(() => {
const getFontFamily = (
elementsArray: readonly ExcalidrawElement[],
elementsMap: Map<string, ExcalidrawElement>,
) =>
getFormValue(
elementsArray,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
return boundTextElement.fontFamily;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(element, elementsMap) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
);
// popup opened, use cached elements
if (
batchedData.openPopup === "fontFamily" &&
appState.openPopup === "fontFamily"
) {
return getFontFamily(
Array.from(cachedElementsRef.current?.values() ?? []),
cachedElementsRef.current,
);
}
// popup closed, use all elements
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
}
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
return prevSelectedFontFamilyRef.current;
}, [batchedData.openPopup, appState, elements, app.scene]);
useEffect(() => {
prevSelectedFontFamilyRef.current = selectedFontFamily;
}, [selectedFontFamily]);
useEffect(() => {
if (Object.keys(batchedData).length) {
updateData(batchedData);
// reset the data after we've used the data
setBatchedData({});
}
// call update only on internal state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchedData]);
useEffect(() => {
isUnmounted.current = false;
return () => {
isUnmounted.current = true;
};
}, []);
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonIconSelect<FontFamilyValues | false>
group="font-family"
options={options}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.fontFamily;
<FontPicker
isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily}
onSelect={(fontFamily) => {
setBatchedData({
openPopup: null,
currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
}}
onHover={(fontFamily) => {
setBatchedData({
currentHoveredFontFamily: fontFamily,
cachedElements: new Map(cachedElementsRef.current),
resetContainers: true,
});
}}
onLeave={() => {
setBatchedData({
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
});
}}
onPopupChange={(open) => {
if (open) {
// open, populate the cache from scratch
cachedElementsRef.current.clear();
const { editingElement } = appState;
if (editingElement?.type === "text") {
// retrieve the latest version from the scene, as `editingElement` isn't mutated
const latestEditingElement = app.scene.getElement(
editingElement.id,
);
// inside the wysiwyg editor
cachedElementsRef.current.set(
editingElement.id,
newElementWith(
latestEditingElement || editingElement,
{},
true,
),
);
} else {
const selectedElements = getSelectedElements(
elements,
appState,
{
includeBoundTextElement: true,
},
);
for (const element of selectedElements) {
cachedElementsRef.current.set(
element.id,
newElementWith(element, {}, true),
);
}
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontFamily;
setBatchedData({
openPopup: "fontFamily",
});
} else {
// close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
} as ChangeFontFamilyData;
if (isUnmounted.current) {
// in case the component was unmounted by the parent, trigger the update directly
updateData({ ...batchedData, ...data });
} else {
setBatchedData(data);
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
cachedElementsRef.current.clear();
}
}}
/>
</fieldset>
);
@ -1262,33 +1556,36 @@ export const actionChangeArrowType = register({
}
const newElement = newElementWith(el, {
roundness:
value === "round"
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === "elbow",
elbowed: value === ARROW_TYPE.elbow,
points:
value === "elbow"
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
if (value === "elbow") {
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
const [startPoint, endPoint] = getArrowLocalFixedPoints(
newElement,
elementsMap,
);
const startGlobalPoint = [
newElement.x + startPoint[0],
newElement.y + startPoint[1],
] as Point;
const endGlobalPoint = [
newElement.x + endPoint[0],
newElement.y + endPoint[1],
] as Point;
const startElement =
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
@ -1296,15 +1593,7 @@ export const actionChangeArrowType = register({
elementsMap,
true,
);
const finalStartPoint = startElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startElement,
elementsMap,
)
: startGlobalPoint;
const endElement =
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
@ -1312,18 +1601,51 @@ export const actionChangeArrowType = register({
elementsMap,
true,
);
const finalEndPoint = endElement
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endElement,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startElement &&
bindLinearElement(newElement, startElement, "start", elementsMap);
endElement &&
bindLinearElement(newElement, endElement, "end", elementsMap);
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
app.scene,
@ -1332,6 +1654,49 @@ export const actionChangeArrowType = register({
[point[0] - newElement.x, point[1] - newElement.y] as Point,
),
[0, 0],
{
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
},
);
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
}
@ -1339,8 +1704,7 @@ export const actionChangeArrowType = register({
}),
appState: {
...appState,
currentItemRoundness: value === "round" ? value : null,
currentItemElbowArrow: value === "elbow",
currentItemArrowType: value,
},
storeAction: StoreAction.CAPTURE,
};
@ -1353,19 +1717,22 @@ export const actionChangeArrowType = register({
group="arrowtypes"
options={[
{
value: "sharp",
value: ARROW_TYPE.sharp,
text: t("labels.arrowtype_sharp"),
icon: arrowUpRightIcon,
icon: sharpArrowIcon,
testId: "sharp-arrow",
},
{
value: "round",
value: ARROW_TYPE.round,
text: t("labels.arrowtype_round"),
icon: arrowCurveRight,
icon: roundArrowIcon,
testId: "round-arrow",
},
{
value: "elbow",
value: ARROW_TYPE.elbow,
text: t("labels.arrowtype_elbowed"),
icon: arrowGuideIcon,
icon: elbowArrowIcon,
testId: "elbow-arrow",
},
]}
value={getFormValue(
@ -1374,23 +1741,17 @@ export const actionChangeArrowType = register({
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? "elbow"
? ARROW_TYPE.elbow
: element.roundness
? "round"
: "sharp";
? ARROW_TYPE.round
: ARROW_TYPE.sharp;
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection
? null
: appState.currentItemElbowArrow
? "elbow"
: appState.currentItemRoundness
? "round"
: "sharp",
hasSelection ? null : appState.currentItemArrowType,
)}
onChange={(value) => updateData(value)}
/>

View File

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

View File

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

View File

@ -27,7 +27,7 @@ export default class BinaryHeap<T> {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0; // MARK
let child1Score = 0;
if (child1N < length) {
const child1 = this.content[child1N];
@ -84,7 +84,6 @@ export default class BinaryHeap<T> {
const i = this.content.indexOf(node);
const end = this.content.pop()!;
// MARK
if (i < this.content.length) {
this.content[i] = end;

View File

@ -165,10 +165,8 @@ export const SelectedShapeActions = ({
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}

View File

@ -48,7 +48,7 @@ import {
} from "../appState";
import type { PastedMixedContent } from "../clipboard";
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
import type { EXPORT_IMAGE_TYPES } from "../constants";
import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants";
import {
APP_NAME,
CURSOR_TYPE,
@ -157,7 +157,6 @@ import {
isLinearElement,
isLinearElementType,
isUsingAdaptiveRadius,
isFrameElement,
isIframeElement,
isIframeLikeElement,
isMagicFrameElement,
@ -227,8 +226,7 @@ import type {
ScrollBars,
} from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, getElementShape } from "../shapes";
import type { GeometricShape } from "../../utils/geometry/shape";
import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes";
import { getSelectionBoxShape } from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision";
import type {
@ -324,7 +322,6 @@ import {
getBoundTextElement,
getContainerCenter,
getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getMinTextElementWidth,
isMeasureTextSupported,
@ -340,7 +337,7 @@ import {
import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts";
import { Fonts, getLineHeight } from "../fonts";
import {
getFrameChildren,
isCursorInFrame,
@ -536,8 +533,8 @@ class App extends React.Component<AppProps, AppState> {
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
public scene: Scene;
public fonts: Fonts;
public renderer: Renderer;
private fonts: Fonts;
private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"];
@ -1294,15 +1291,7 @@ class App extends React.Component<AppProps, AppState> {
const isDarkTheme = this.state.theme === THEME.DARK;
let frameIndex = 0;
let magicFrameIndex = 0;
return this.scene.getNonDeletedFramesLikes().map((f) => {
if (isFrameElement(f)) {
frameIndex++;
} else {
magicFrameIndex++;
}
if (
!isElementInViewport(
f,
@ -1336,10 +1325,7 @@ class App extends React.Component<AppProps, AppState> {
let frameNameJSX;
const frameName = getFrameLikeTitle(
f,
isFrameElement(f) ? frameIndex : magicFrameIndex,
);
const frameName = getFrameLikeTitle(f);
if (f.id === this.state.editingFrame) {
const frameNameInEdit = frameName;
@ -2130,6 +2116,14 @@ class App extends React.Component<AppProps, AppState> {
});
};
public dismissLinearEditor = () => {
setTimeout(() => {
this.setState({
editingLinearElement: null,
});
});
};
public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) {
return;
@ -2339,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.resetHistory();
@ -2351,6 +2340,12 @@ class App extends React.Component<AppProps, AppState> {
...scene,
storeAction: StoreAction.UPDATE,
});
// FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all
// fonts and rerender scene text elements once done. This also
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadSceneFonts();
};
private isMobileBreakpoint = (width: number, height: number) => {
@ -2443,6 +2438,10 @@ class App extends React.Component<AppProps, AppState> {
configurable: true,
value: this.store,
},
fonts: {
configurable: true,
value: this.fonts,
},
});
}
@ -2580,7 +2579,7 @@ class App extends React.Component<AppProps, AppState> {
// rerender text elements on font load to fix #637 && #1553
addEventListener(document.fonts, "loadingdone", (event) => {
const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
this.fonts.onFontsLoaded(loadedFontFaces);
this.fonts.onLoaded(loadedFontFaces);
}),
// Safari-only desktop pinch zoom
addEventListener(
@ -3384,7 +3383,7 @@ class App extends React.Component<AppProps, AppState> {
fontSize: textElementProps.fontSize,
fontFamily: textElementProps.fontFamily,
});
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
const lineHeight = getLineHeight(textElementProps.fontFamily);
const [x1, , x2] = getVisibleSceneBounds(this.state);
// long texts should not go beyond 800 pixels in width nor should it go below 200 px
const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
@ -3402,13 +3401,13 @@ class App extends React.Component<AppProps, AppState> {
});
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)
: originalText;
metrics = isTextWrapped
metrics = isTextUnwrapped
? measureText(text, fontString, lineHeight)
: metrics;
@ -3422,7 +3421,7 @@ class App extends React.Component<AppProps, AppState> {
text,
originalText,
lineHeight,
autoResize: !isTextWrapped,
autoResize: !isTextUnwrapped,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
acc.push(element);
@ -4088,6 +4087,16 @@ class App extends React.Component<AppProps, AppState> {
})`,
);
}
if (shape === "arrow" && this.state.activeTool.type === "arrow") {
this.setState((prevState) => ({
currentItemArrowType:
prevState.currentItemArrowType === ARROW_TYPE.sharp
? ARROW_TYPE.round
: prevState.currentItemArrowType === ARROW_TYPE.round
? ARROW_TYPE.elbow
: ARROW_TYPE.sharp,
}));
}
this.setActiveTool({ type: shape });
event.stopPropagation();
} else if (event.key === KEYS.Q) {
@ -4128,6 +4137,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 (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" });
@ -4504,37 +4543,6 @@ class App extends React.Component<AppProps, AppState> {
return null;
}
private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
const boundTextElement = getBoundTextElement(
element,
this.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
if (element.type === "arrow") {
return 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(),
),
},
this.scene.getNonDeletedElementsMap(),
);
}
return getElementShape(
boundTextElement,
this.scene.getNonDeletedElementsMap(),
);
}
return null;
}
private getElementAtPosition(
x: number,
y: number,
@ -4666,7 +4674,7 @@ class App extends React.Component<AppProps, AppState> {
const hitBoundTextOfElement = hitElementBoundText(
x,
y,
this.getBoundTextShape(element),
getBoundTextShape(element, this.scene.getNonDeletedElementsMap()),
);
if (hitBoundTextOfElement) {
return true;
@ -4784,7 +4792,7 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight =
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
existingTextElement?.lineHeight || getLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize;
if (
@ -4904,7 +4912,9 @@ class App extends React.Component<AppProps, AppState> {
if (
event[KEYS.CTRL_OR_CMD] &&
(!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
this.state.editingLinearElement.elementId !==
selectedElements[0].id) &&
!isElbowArrow(selectedElements[0])
) {
this.store.shouldCaptureIncrement();
this.setState({
@ -5362,7 +5372,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (isElbowArrow(multiElement)) {
mutateElbowArrow(
multiElement as ExcalidrawArrowElement,
multiElement,
this.scene,
[
...points.slice(0, -1),
@ -7067,14 +7077,16 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemRoundness === "round"
this.state.currentItemArrowType === ARROW_TYPE.round
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: null,
: // note, roundness doesn't have any effect for elbow arrows,
// but it's best to set it to null as well
null,
startArrowhead,
endArrowhead,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemElbowArrow,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
})
: newLinearElement({
type: elementType,
@ -7113,6 +7125,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
isElbowArrow(element),
);
this.scene.insertElement(element);
@ -7578,19 +7591,18 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
// TODO: Check if needed to re-add this
// if (
// selectedElements.length !== 1 ||
// !isArrowElement(selectedElements[0]) ||
// !pointerDownState.drag.hasOccurred ||
// !!this.state.editingLinearElement
// ) {
this.setState({
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
),
});
if (
selectedElements.length !== 1 ||
!isElbowArrow(selectedElements[0])
) {
this.setState({
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
),
});
}
//}
// We duplicate the selected element if alt is pressed on pointer move
@ -7733,7 +7745,7 @@ class App extends React.Component<AppProps, AppState> {
});
} else if (points.length > 1 && isElbowArrow(draggingElement)) {
mutateElbowArrow(
draggingElement as ExcalidrawArrowElement,
draggingElement,
this.scene,
[...points.slice(0, -1), [dx, dy]],
[0, 0],

View File

@ -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;
}
}
}

View File

@ -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>
);
},
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}
}

View File

@ -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,
);

View File

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

View File

@ -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>
);
};

View File

@ -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;
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
);
},
);

View File

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

View File

@ -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;
}
}
}

View File

@ -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>
);
},
);

View File

@ -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%;
}
}
}

View File

@ -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>
);
};

View File

@ -1,6 +1,6 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
@ -29,7 +29,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement) {
if (origElement && !isElbowArrow(origElement)) {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;

View File

@ -21,6 +21,7 @@ import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
interface StatsProps {
scene: Scene;
@ -209,12 +210,14 @@ export const StatsInner = memo(
scene={scene}
appState={appState}
/>
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
{!isElbowArrow(singleElement) && (
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
)}
<FontSize
property="fontSize"
element={singleElement}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1438,6 +1438,27 @@ export const fontSizeIcon = createIcon(
tablerIconProps,
);
export const FontFamilyHeadingIcon = createIcon(
<>
<g
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 12h10" />
<path d="M7 5v14" />
<path d="M17 5v14" />
<path d="M15 19h4" />
<path d="M15 5h4" />
<path d="M5 19h4" />
<path d="M5 5h4" />
</g>
</>,
tablerIconProps,
);
export const FontFamilyNormalIcon = createIcon(
<>
<g
@ -2074,33 +2095,31 @@ export const lineEditorIcon = createIcon(
tablerIconProps,
);
// arrow-up-right
export const arrowUpRightIcon = createIcon(
// arrow-up-right (modified)
export const sharpArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8.464 15.536l9.536 -9.536" />
<path d="M6 18l12 -12" />
<path d="M18 10v-4h-4" />
<path d="M8.414 15.586a2 2 0 1 0 -2.828 2.828a2 2 0 0 0 2.828 -2.828" />
</g>,
tablerIconProps,
);
// arrow-guide
export const arrowGuideIcon = createIcon(
// arrow-guide (modified)
export const elbowArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M7 19h3a2 2 0 0 0 2 -2v-8a2 2 0 0 1 2 -2h7" />
<path d="M4,19L10,19C11.097,19 12,18.097 12,17L12,9C12,7.903 12.903,7 14,7L21,7" />
<path d="M18 4l3 3l-3 3" />
</g>,
tablerIconProps,
);
export const arrowCurveRight = createIcon(
// arrow-ramp-right-2 (heavily modified)
export const roundArrowIcon = createIcon(
<g>
<path d="M15.6819 3.18201L18.6819 6.18201L15.6819 9.18201" />
<path d="M8.24264 18.2426C9.02369 17.4615 9.02369 16.1952 8.24264 15.4142C7.46159 14.6331 6.19526 14.6331 5.41421 15.4142C4.63316 16.1952 4.63316 17.4615 5.41421 18.2426C6.19526 19.0236 7.46159 19.0236 8.24264 18.2426Z" />
<path d="M7.00011 15.0001C7.00011 15.0001 6.06372 9.82945 8.5 7.5C11.1852 4.93253 18.0001 6.00005 18.0001 6.00005" />
<path d="M16,12L20,9L16,6" />
<path d="M6 20c0 -6.075 4.925 -11 11 -11h3" />
</g>,
tablerIconProps,
);

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss";
import type { AppProps } from "./types";
import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@ -114,12 +114,24 @@ export const CLASSES = {
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 = {
Virgil: 1,
Helvetica: 2,
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 = {
@ -147,7 +159,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
@ -409,3 +421,9 @@ export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1;
export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
sharp: "sharp",
round: "round",
elbow: "elbow",
};

View File

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

View File

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

View File

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

View File

@ -84,13 +84,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": [
0.04,
0.4633333333333333,
],
"fixedPoint": null,
"focus": -0.008153707962747813,
"gap": 1,
},
@ -121,10 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "id47",
"fixedPoint": [
1.0166666666666666,
0.5,
],
"fixedPoint": null,
"focus": -0.08139534883720931,
"gap": 1,
},
@ -147,13 +142,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": [
-0.01,
0.44666666666666666,
],
"fixedPoint": null,
"focus": 0.10666666666666667,
"gap": 3.834326468444573,
},
@ -184,10 +177,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "diamond-1",
"fixedPoint": [
0.9357142857142857,
-0.05,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -255,7 +245,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -301,7 +291,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -344,13 +334,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "text-2",
"fixedPoint": [
-2.05,
0.5,
],
"fixedPoint": null,
"focus": 0,
"gap": 205,
},
@ -381,10 +369,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null,
"startBinding": {
"elementId": "text-1",
"fixedPoint": [
1.0714285714285714,
0.5,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -410,7 +395,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"containerId": "id48",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -453,13 +438,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id40",
"fixedPoint": [
1,
0.5,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -490,10 +473,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null,
"startBinding": {
"elementId": "id39",
"fixedPoint": [
1.05,
0.5,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -519,7 +499,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"containerId": "id37",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -636,13 +616,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id44",
"fixedPoint": [
-0.05,
0.5,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -673,10 +651,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null,
"startBinding": {
"elementId": "id43",
"fixedPoint": [
1.0714285714285714,
0.5,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -702,7 +677,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": "id41",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -748,7 +723,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -794,7 +769,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -864,7 +839,8 @@ exports[`Test Transform > should transform linear elements 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@ -911,7 +887,8 @@ exports[`Test Transform > should transform linear elements 2`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "triangle",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@ -937,7 +914,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startArrowhead": "dot",
"startBinding": null,
"strokeColor": "#1971c2",
"strokeStyle": "solid",
@ -1247,7 +1224,7 @@ exports[`Test Transform > should transform text element 1`] = `
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -1288,7 +1265,7 @@ exports[`Test Transform > should transform text element 2`] = `
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -1503,13 +1480,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "Alice",
"fixedPoint": [
-0.07115855014454081,
0.5,
],
"fixedPoint": null,
"focus": 0,
"gap": 5.299874999999986,
},
@ -1542,10 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": [
1.0885078135804176,
0.5,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -1573,13 +1545,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "B",
"fixedPoint": [
-0.030114812723508376,
0.48466257668711654,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -1608,10 +1578,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": [
0.39381496335223337,
1.1136363636363635,
],
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -1637,7 +1604,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "B",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [
@ -1680,7 +1647,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "A",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [
@ -1723,7 +1690,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Alice",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [
@ -1766,7 +1733,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [
@ -1809,7 +1776,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_Alice",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -1850,7 +1817,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"containerId": "Bob_B",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -1893,7 +1860,8 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@ -1945,7 +1913,8 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@ -1997,7 +1966,8 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@ -2049,7 +2019,8 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"endArrowhead": null,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
@ -2099,7 +2070,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id25",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2140,7 +2111,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id26",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2181,7 +2152,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id27",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2223,7 +2194,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"containerId": "id28",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2487,7 +2458,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id13",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2528,7 +2499,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id14",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2570,7 +2541,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id15",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2614,7 +2585,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id16",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2656,7 +2627,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id17",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
@ -2699,7 +2670,7 @@ exports[`Test Transform > should transform to text containers when label provide
"containerId": "id18",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 1,
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],

View File

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

View File

@ -25,6 +25,7 @@ import {
} from "../element";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
isTextElement,
isUsingAdaptiveRadius,
@ -45,14 +46,11 @@ import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getContainerElement,
getDefaultLineHeight,
} from "../element/textElement";
import { detectLineHeight, getContainerElement } from "../element/textElement";
import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
import { getSizeFromPoints } from "../points";
import { getLineHeight } from "../fonts";
type RestoredAppState = Omit<
AppState,
@ -96,12 +94,21 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (binding: PointBinding | null) => {
const repairBinding = (
element: ExcalidrawLinearElement,
binding: PointBinding | null,
): PointBinding | null => {
if (!binding) {
return null;
}
return { ...binding, focus: binding.focus || 0, focusPoint: [0, 0] };
return {
...binding,
focus: binding.focus || 0,
fixedPoint: isElbowArrow(element)
? binding.fixedPoint ?? ([0, 0] as [number, number])
: null,
};
};
const restoreElementWithProperties = <
@ -208,7 +215,7 @@ const restoreElement = (
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
getLineHeight(element.fontFamily));
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
@ -267,8 +274,8 @@ const restoreElement = (
(element.type as ExcalidrawElementType | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element.startBinding),
endBinding: repairBinding(element.endBinding),
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
@ -296,8 +303,8 @@ const restoreElement = (
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
type: element.type,
startBinding: repairBinding(element.startBinding),
endBinding: repairBinding(element.endBinding),
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,

View File

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

View File

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

View File

@ -23,6 +23,8 @@ import type {
ExcalidrawTextElement,
ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement,
FixedPoint,
} from "./types";
import type { Bounds } from "./bounds";
@ -36,6 +38,7 @@ import {
isBindingElement,
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
@ -47,32 +50,29 @@ import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes";
import { headingForPointFromElement } from "./routing";
import type { Heading } from "../math";
import {
aabbForElement,
compareHeading,
clamp,
distanceSq2d,
getCenterForBounds,
getCenterForElement,
getCornerRadius,
pointInsideBounds,
pointToVector,
rotatePoint,
} from "../math";
import {
compareHeading,
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
pointInsideBounds,
pointToVector,
rotatePoint,
headingForPointFromElement,
vectorToHeading,
} from "../math";
import {
debugDrawBounds,
debugDrawPoint,
debugDrawSegments,
} from "../visualdebug";
import {
interceptPointsOfLineAndEllipse,
interceptPointsOfSegmentAndRoundedRectangle,
} from "../../utils/geometry/geometry";
import type { LineSegment } from "../../utils/geometry/shape";
type Heading,
} from "./heading";
import { interceptPointsOfLineAndEllipse, interceptPointsOfSegmentAndRoundedRectangle } from "../../utils/geometry/geometry";
import { LineSegment } from "../../utils/geometry/shape";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@ -432,22 +432,26 @@ export const bindLinearElement = (
if (!isArrowElement(linearElement)) {
return;
}
const binding: PointBinding = {
elementId: hoveredElement.id,
...calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
...(isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
)
: { fixedPoint: null }),
};
mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
elementId: hoveredElement.id,
...calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
...calculateFixedPointForElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
} as PointBinding,
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
@ -517,7 +521,14 @@ export const getHoveredElementForBinding = (
elements,
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords, elementsMap, fullShape),
bindingBorderTest(
element,
pointerCoords,
elementsMap,
// disable fullshape snapping for frame elements so we
// can bind to frame children
fullShape && !isFrameLikeElement(element),
),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@ -687,13 +698,42 @@ const getSimultaneouslyUpdatedElementIds = (
return new Set((simultaneouslyUpdated || []).map((element) => element.id));
};
// TODO: See if it can be merged with routing.ts: getBindPointHeading()
const getHeadingForElbowArrowSnap = (
point: Point,
otherPoint: Point,
export const getHeadingForElbowArrowSnap = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: Point,
): Heading => {
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
if (!bindableElement || !aabb) {
return otherPointHeading;
}
const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
);
if (!distance) {
return vectorToHeading(
pointToVector(point, getCenterForElement(bindableElement)),
);
}
const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
return pointHeading;
};
const getDistanceForBinding = (
point: Readonly<Point>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): Heading | null => {
) => {
const distance = distanceToBindableElement(
bindableElement,
point,
@ -705,79 +745,61 @@ const getHeadingForElbowArrowSnap = (
bindableElement.height,
);
if (distance > bindDistance) {
return null;
}
const pointHeading = headingForPointFromElement(
bindableElement,
aabbForElement(
bindableElement,
Array(4).fill(
distanceToBindableElement(bindableElement, point, elementsMap),
) as [number, number, number, number],
),
point,
);
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
const isInner =
otherPointHeading === HEADING_LEFT || otherPointHeading === HEADING_RIGHT
? distance < bindableElement.width * -0.2
: distance < bindableElement.height * -0.2;
return isInner ? otherPointHeading : pointHeading;
return distance > bindDistance ? null : distance;
};
export const bindPointToSnapToElementOutline = (
point: Point,
otherPoint: Point,
bindableElement: ExcalidrawBindableElement,
point: Readonly<Point>,
otherPoint: Readonly<Point>,
bindableElement: ExcalidrawBindableElement | undefined,
elementsMap: ElementsMap,
): Point => {
const aabb = aabbForElement(bindableElement, [
FIXED_BINDING_DISTANCE,
FIXED_BINDING_DISTANCE,
FIXED_BINDING_DISTANCE,
FIXED_BINDING_DISTANCE,
]);
const center = [(aabb[0] + aabb[2]) / 2, (aabb[1] + aabb[3]) / 2] as Point;
const aabb = bindableElement && aabbForElement(bindableElement);
const heading = getHeadingForElbowArrowSnap(
point,
otherPoint,
bindableElement,
elementsMap,
);
//debugDrawBounds(aabb);
//debugDrawPoint(point, "green");
if (heading) {
const headingIsVertical =
compareHeading(heading, HEADING_UP) ||
compareHeading(heading, HEADING_DOWN);
const intersections = _intersectElementWithLine(
bindableElement,
headingIsVertical
? [point[0], point[1] - 2 * bindableElement.height]
: [point[0] - 2 * bindableElement.width, point[1]],
headingIsVertical
? [point[0], point[1] + 2 * bindableElement.height]
: [point[0] + 2 * bindableElement.width, point[1]],
FIXED_BINDING_DISTANCE,
if (bindableElement && aabb) {
// TODO: Dirty hack until tangents are properly calculated
const intersections = [
...intersectElementWithLine(
bindableElement,
[point[0], point[1] - 2 * bindableElement.height],
[point[0], point[1] + 2 * bindableElement.height],
FIXED_BINDING_DISTANCE,
elementsMap,
),
...intersectElementWithLine(
bindableElement,
[point[0] - 2 * bindableElement.width, point[1]],
[point[0] + 2 * bindableElement.width, point[1]],
FIXED_BINDING_DISTANCE,
elementsMap,
),
].map((i) =>
distanceToBindableElement(bindableElement, i, elementsMap) >
Math.min(bindableElement.width, bindableElement.height) / 2
? ([-1 * i[0], -1 * i[1]] as Point)
: i,
);
const heading = headingForPointFromElement(bindableElement, aabb, point);
const isVertical =
compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT);
const dist = distanceToBindableElement(bindableElement, point, elementsMap);
const isInner = isVertical
? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1;
intersections.sort(
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
);
debugDrawSegments([
headingIsVertical
? [point[0], point[1] - 2 * bindableElement.height]
: [point[0] - 2 * bindableElement.width, point[1]],
headingIsVertical
? [point[0], point[1] + 2 * bindableElement.height]
: [point[0] + 2 * bindableElement.width, point[1]],
]);
if (intersections.length > 0) {
intersections.forEach((point) => debugDrawPoint(point, "red"));
return intersections[0];
}
return isInner
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
: intersections.filter((i) =>
isVertical
? Math.abs(point[1] - i[1]) < 0.1
: Math.abs(point[0] - i[0]) < 0.1,
)[0] ?? point;
}
return point;
@ -836,7 +858,42 @@ export const _intersectElementWithLine = (
halfWidth: halfWidth + gap,
halfHeight: halfHeight + gap,
},
[a, b] as LineSegment,
[a, b] as LineSegment, );
}
};
const headingToMidBindPoint = (
point: Point,
bindableElement: ExcalidrawBindableElement,
aabb: Bounds,
): Point => {
const center = getCenterForBounds(aabb);
const heading = vectorToHeading(pointToVector(point, center));
switch (true) {
case compareHeading(heading, HEADING_UP):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_RIGHT):
return rotatePoint(
[aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_DOWN):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
center,
bindableElement.angle,
);
default:
return rotatePoint(
[aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
center,
bindableElement.angle,
);
}
};
@ -845,41 +902,86 @@ export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: Point,
): Point => {
// NOTE: Only relevant at angle = 0, so no rotation handling
const center = getCenterForElement(element);
const nonRotatedPoint = rotatePoint(p, center, -element.angle);
if (p[0] < element.x && p[1] < element.y) {
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
if (p[1] - element.y > -FIXED_BINDING_DISTANCE) {
return [element.x - FIXED_BINDING_DISTANCE, element.y];
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y],
center,
element.angle,
);
}
return [element.x, element.y - FIXED_BINDING_DISTANCE];
} else if (p[0] < element.x && p[1] > element.y + element.height) {
// Bottom left
if (p[0] - element.x > -FIXED_BINDING_DISTANCE) {
return [element.x, element.y + element.height + FIXED_BINDING_DISTANCE];
}
return [element.x - FIXED_BINDING_DISTANCE, element.y + element.height];
return rotatePoint(
[element.x, element.y - FIXED_BINDING_DISTANCE],
center,
element.angle,
);
} else if (
p[0] > element.x + element.width &&
p[1] > element.y + element.height
nonRotatedPoint[0] < element.x &&
nonRotatedPoint[1] > element.y + element.height
) {
// Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
center,
element.angle,
);
}
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
center,
element.angle,
);
} else if (
nonRotatedPoint[0] > element.x + element.width &&
nonRotatedPoint[1] > element.y + element.height
) {
// Bottom right
if (p[0] - element.x < element.width + FIXED_BINDING_DISTANCE) {
return [
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
];
if (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
],
center,
element.angle,
);
}
return [
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
];
} else if (p[0] > element.x + element.width && p[1] < element.y) {
return rotatePoint(
[
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
],
center,
element.angle,
);
} else if (
nonRotatedPoint[0] > element.x + element.width &&
nonRotatedPoint[1] < element.y
) {
// Top right
if (p[0] - element.x < element.width + FIXED_BINDING_DISTANCE) {
return [element.x + element.width, element.y - FIXED_BINDING_DISTANCE];
if (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
center,
element.angle,
);
}
return [element.x + element.width + FIXED_BINDING_DISTANCE, element.y];
return rotatePoint(
[element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
center,
element.angle,
);
}
return p;
@ -894,24 +996,29 @@ export const snapToMid = (
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
const nonRotated = rotatePoint(p, center, -angle);
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - tolerance * height &&
nonRotated[1] < center[1] + tolerance * height
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
) {
// LEFT
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - tolerance * width &&
nonRotated[0] < center[0] + tolerance * width
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
) {
// TOP
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - tolerance * height &&
nonRotated[1] < center[1] + tolerance * height
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
) {
// RIGHT
return rotatePoint(
@ -921,8 +1028,8 @@ export const snapToMid = (
);
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - tolerance * width &&
nonRotated[0] < center[0] + tolerance * width
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
) {
// DOWN
return rotatePoint(
@ -955,14 +1062,14 @@ const updateBoundPoint = (
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement)) {
const { fixedPoint } =
binding ??
const fixedPoint =
binding.fixedPoint ??
calculateFixedPointForElbowArrowBinding(
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
);
).fixedPoint;
const globalMidPoint = [
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
@ -1028,12 +1135,12 @@ const updateBoundPoint = (
);
};
const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
) => {
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
hoveredElement.y,
@ -1042,39 +1149,39 @@ const calculateFixedPointForElbowArrowBinding = (
] as Bounds;
const edgePointIndex =
startOrEnd === "start" ? 0 : linearElement.points.length - 1;
const otherPointIndex =
startOrEnd === "end" ? 0 : linearElement.points.length - 1;
const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
const otherGlobalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
const snappedPoint = bindPointToSnapToElementOutline(
globalPoint,
otherGlobalPoint,
hoveredElement,
elementsMap,
);
const globalMidPoint = [
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
] as Point;
const nonRotatedGlobalPoint = rotatePoint(
globalPoint,
const nonRotatedSnappedGlobalPoint = rotatePoint(
snappedPoint,
globalMidPoint,
-hoveredElement.angle,
) as Point;
const otherPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
otherPointIndex,
elementsMap,
);
const snappedPoint = bindPointToSnapToElementOutline(
nonRotatedGlobalPoint,
otherPoint,
hoveredElement,
elementsMap,
);
return {
fixedPoint: [
(snappedPoint[0] - hoveredElement.x) / hoveredElement.width,
(snappedPoint[1] - hoveredElement.y) / hoveredElement.height,
] as Point,
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
hoveredElement.height,
] as [number, number],
};
};
@ -1086,18 +1193,18 @@ const maybeCalculateNewGapWhenScaling = (
if (currentBinding == null || newSize == null) {
return currentBinding;
}
const { gap, focus, elementId, fixedPoint } = currentBinding;
const { width: newWidth, height: newHeight } = newSize;
const { width, height } = changedElement;
const newGap = Math.max(
1,
Math.min(
maxBindingGap(changedElement, newWidth, newHeight),
gap * (newWidth < newHeight ? newWidth / width : newHeight / height),
currentBinding.gap *
(newWidth < newHeight ? newWidth / width : newHeight / height),
),
);
return { elementId, gap: newGap, focus, fixedPoint };
return { ...currentBinding, gap: newGap };
};
const getElligibleElementForBindingElement = (
@ -1225,12 +1332,9 @@ const newBindingAfterDuplication = (
if (binding == null) {
return null;
}
const { elementId, focus, gap, fixedPoint } = binding;
return {
focus,
gap,
fixedPoint,
elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
...binding,
elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
};
};
@ -2125,3 +2229,62 @@ export class BindableElement {
);
};
}
export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
element: ExcalidrawBindableElement,
) => {
const [fixedX, fixedY] = fixedPointRatio;
return rotatePoint(
[element.x + element.width * fixedX, element.y + element.height * fixedY],
getCenterForElement(element),
element.angle,
);
};
const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
) => {
const startElement =
arrow.startBinding &&
(elementsMap.get(arrow.startBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const endElement =
arrow.endBinding &&
(elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const startPoint: Point =
startElement && arrow.startBinding
? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
)
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
const endPoint: Point =
endElement && arrow.endBinding
? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
)
: [
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
];
return [startPoint, endPoint];
};
export const getArrowLocalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
) => {
const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap);
return [
LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap),
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
];
};

View File

@ -18,6 +18,7 @@ import {
isImageElement,
isTextElement,
} from "./typeChecks";
import { getBoundTextShape } from "../shapes";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@ -97,6 +98,12 @@ export const hitElementBoundingBoxOnly = (
) => {
return (
!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)
);
};
@ -105,6 +112,6 @@ export const hitElementBoundText = (
x: number,
y: number,
textShape: GeometricShape | null,
) => {
return textShape && isPointInShape([x, y], textShape);
): boolean => {
return !!textShape && isPointInShape([x, y], textShape);
};

View File

@ -19,7 +19,7 @@ import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
selectedElements: NonDeletedExcalidrawElement[],
_selectedElements: NonDeletedExcalidrawElement[],
offset: { x: number; y: number },
scene: Scene,
snapOffset: {
@ -29,13 +29,24 @@ export const dragSelectedElements = (
gridSize: AppState["gridSize"],
) => {
if (
selectedElements.length === 1 &&
isArrowElement(selectedElements[0]) &&
isElbowArrow(selectedElements[0]) &&
(selectedElements[0].startBinding || selectedElements[0].endBinding)
_selectedElements.length === 1 &&
isArrowElement(_selectedElements[0]) &&
isElbowArrow(_selectedElements[0]) &&
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) {
return;
}
const selectedElements = _selectedElements.filter(
(el) =>
!(
isArrowElement(el) &&
isElbowArrow(el) &&
el.startBinding &&
el.endBinding
),
);
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set

View File

@ -0,0 +1,146 @@
import { lineAngle } from "../../utils/geometry/geometry";
import type { Point, Vector } from "../../utils/geometry/shape";
import {
getCenterForBounds,
PointInTriangle,
rotatePoint,
scalePointFromOrigin,
} from "../math";
import type { Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
export const HEADING_DOWN = [0, 1] as Heading;
export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const headingForDiamond = (a: Point, b: Point) => {
const angle = lineAngle([a, b]);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
return HEADING_RIGHT;
} else if (angle >= 135 && angle < 225) {
return HEADING_DOWN;
}
return HEADING_LEFT;
};
export const vectorToHeading = (vec: Vector): Heading => {
const [x, y] = vec;
const absX = Math.abs(x);
const absY = Math.abs(y);
if (x > absY) {
return HEADING_RIGHT;
} else if (x <= -absY) {
return HEADING_LEFT;
} else if (y > absX) {
return HEADING_DOWN;
}
return HEADING_UP;
};
export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1];
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
export const headingForPointFromElement = (
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<Point>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
if (point[0] < element.x) {
return HEADING_LEFT;
} else if (point[1] < element.y) {
return HEADING_UP;
} else if (point[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (point[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const right = rotatePoint(
scalePointFromOrigin(
[element.x + element.width, element.y + element.height / 2],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y + element.height],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const left = rotatePoint(
scalePointFromOrigin(
[element.x, element.y + element.height / 2],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
if (PointInTriangle(point, top, right, midPoint)) {
return headingForDiamond(top, right);
} else if (PointInTriangle(point, right, bottom, midPoint)) {
return headingForDiamond(right, bottom);
} else if (PointInTriangle(point, bottom, left, midPoint)) {
return headingForDiamond(bottom, left);
}
return headingForDiamond(left, top);
}
const topLeft = scalePointFromOrigin(
[aabb[0], aabb[1]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const topRight = scalePointFromOrigin(
[aabb[2], aabb[1]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomLeft = scalePointFromOrigin(
[aabb[0], aabb[3]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomRight = scalePointFromOrigin(
[aabb[2], aabb[3]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
return PointInTriangle(point, topLeft, topRight, midPoint)
? HEADING_UP
: PointInTriangle(point, topRight, bottomRight, midPoint)
? HEADING_RIGHT
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
? HEADING_DOWN
: HEADING_LEFT;
};

View File

@ -7,8 +7,8 @@ import type {
ExcalidrawTextElementWithContainer,
ElementsMap,
NonDeletedSceneElementsMap,
ExcalidrawArrowElement,
OrderedExcalidrawElement,
FixedPointBinding,
} from "./types";
import {
distance2d,
@ -44,7 +44,11 @@ import {
isBindingEnabled,
} from "./binding";
import { tupleToCoors } from "../utils";
import { isBindingElement, isElbowArrow } from "./typeChecks";
import {
isBindingElement,
isElbowArrow,
isFixedPointBinding,
} from "./typeChecks";
import { KEYS, shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants";
@ -1423,12 +1427,31 @@ export class LinearElementEditor {
},
) {
if (isElbowArrow(element)) {
const bindings: {
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
} = {};
if (otherUpdates?.startBinding !== undefined) {
bindings.startBinding =
otherUpdates.startBinding !== null &&
isFixedPointBinding(otherUpdates.startBinding)
? otherUpdates.startBinding
: null;
}
if (otherUpdates?.endBinding !== undefined) {
bindings.endBinding =
otherUpdates.endBinding !== null &&
isFixedPointBinding(otherUpdates.endBinding)
? otherUpdates.endBinding
: null;
}
mutateElbowArrow(
element as ExcalidrawArrowElement,
element,
scene,
nextPoints,
[offsetX, offsetY],
otherUpdates,
bindings,
options,
);
} else {

View File

@ -107,6 +107,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
/** pass `true` to always regenerate */
force = false,
): TElement => {
let didChange = false;
for (const key in updates) {
@ -123,7 +125,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
}
}
if (!didChange) {
if (!didChange && !force) {
return element;
}

View File

@ -37,7 +37,6 @@ import {
normalizeText,
wrapText,
getBoundTextMaxWidth,
getDefaultLineHeight,
} from "./textElement";
import {
DEFAULT_ELEMENT_PROPS,
@ -48,6 +47,7 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@ -229,7 +229,7 @@ export const newTextElement = (
): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
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 metrics = measureText(
text,
@ -534,7 +534,7 @@ export const regenerateId = (
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el) => el.id === nextId)
.find((el: ExcalidrawElement) => el.id === nextId)
) {
nextId += "_copy";
}

View File

@ -31,7 +31,7 @@ import {
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
import type {
MaybeTransformHandleType,
TransformHandleDirection,
@ -52,7 +52,7 @@ import {
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { getArrowLocalFixedPoints, mutateElbowArrow } from "./routing";
import { mutateElbowArrow } from "./routing";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {

View File

@ -0,0 +1,216 @@
import React from "react";
import Scene from "../scene/Scene";
import { API } from "../tests/helpers/api";
import { Pointer, UI } from "../tests/helpers/ui";
import {
fireEvent,
GlobalTestState,
queryByTestId,
render,
} from "../tests/test-utils";
import { bindLinearElement } from "./binding";
import { Excalidraw } from "../index";
import { mutateElbowArrow } from "./routing";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
} from "./types";
import { ARROW_TYPE } from "../constants";
const { h } = window;
const mouse = new Pointer("mouse");
const editInput = (input: HTMLInputElement, value: string) => {
input.focus();
fireEvent.change(input, { target: { value } });
input.blur();
};
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
const arrow = API.createElement({
type: "arrow",
elbowed: true,
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene, [
[-45 - arrow.x, -100.1 - arrow.y],
[45 - arrow.x, 99.9 - arrow.y],
]);
expect(arrow.points).toEqual([
[0, 0],
[0, 100],
[90, 100],
[90, 200],
]);
expect(arrow.x).toEqual(-45);
expect(arrow.y).toEqual(-100.1);
expect(arrow.width).toEqual(90);
expect(arrow.height).toEqual(200);
});
it("can generate proper points for bound elbow arrow", () => {
const scene = new Scene();
const rectangle1 = API.createElement({
type: "rectangle",
x: -150,
y: -150,
width: 100,
height: 100,
}) as ExcalidrawBindableElement;
const rectangle2 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
}) as ExcalidrawBindableElement;
const arrow = API.createElement({
type: "arrow",
elbowed: true,
x: -45,
y: -100.1,
width: 90,
height: 200,
points: [
[0, 0],
[90, 200],
],
}) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(arrow, rectangle1, "start", elementsMap);
bindLinearElement(arrow, rectangle2, "end", elementsMap);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, scene, [
[0, 0],
[90, 200],
]);
expect(arrow.points).toEqual([
[0, 0],
[45, 0],
[45, 200],
[90, 200],
]);
});
});
describe("elbow arrow ui", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can follow bound shapes", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.type).toBe("arrow");
expect(arrow.elbowed).toBe(true);
expect(arrow.points).toEqual([
[0, 0],
[35, 0],
[35, 200],
[90, 200],
]);
});
it("can follow bound rotated shapes", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
mouse.click(51, 51);
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
editInput(inputAngle, String("40"));
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0],
[35, 0],
[35, 90],
[25, 90],
[25, 165],
[103, 165],
]);
});
});

View File

@ -1,49 +1,49 @@
import { cross } from "../../utils/geometry/geometry";
import BinaryHeap from "../binaryheap";
import type { Heading } from "../math";
import {
aabbForElement,
arePointsEqual,
pointInsideBounds,
pointToVector,
scalePointFromOrigin,
scaleVector,
translatePoint,
} from "../math";
import { getSizeFromPoints } from "../points";
import type Scene from "../scene/Scene";
import type { Point } from "../types";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import {
bindPointToSnapToElementOutline,
distanceToBindableElement,
avoidRectangularCorner,
getHoveredElementForBinding,
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
snapToMid,
} from "./binding";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import {
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
PointInTriangle,
aabbForElement,
arePointsEqual,
isAnyTrue,
pointInsideBounds,
pointToVector,
rotatePoint,
scalePointFromOrigin,
scaleVector,
translatePoint,
vectorToHeading,
} from "../math";
import { getSizeFromPoints } from "../points";
import type Scene from "../scene/Scene";
import type { Point } from "../types";
import { toBrandedType, tupleToCoors } from "../utils";
import {
bindPointToSnapToElementOutline,
distanceToBindableElement,
avoidRectangularCorner,
snapToMid,
getHoveredElementForBinding,
FIXED_BINDING_DISTANCE,
} from "./binding";
import type { Bounds } from "./bounds";
import { LinearElementEditor } from "./linearElementEditor";
} from "./heading";
import { mutateElement } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import type {
ExcalidrawElbowArrowElement,
FixedPointBinding,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
import {
type ElementsMap,
type ExcalidrawArrowElement,
type ExcalidrawBindableElement,
type OrderedExcalidrawElement,
type PointBinding,
import type {
ElementsMap,
ExcalidrawBindableElement,
OrderedExcalidrawElement,
} from "./types";
type Node = {
@ -66,13 +66,13 @@ type Grid = {
const BASE_PADDING = 40;
export const mutateElbowArrow = (
arrow: ExcalidrawArrowElement,
arrow: ExcalidrawElbowArrowElement,
scene: Scene,
nextPoints: readonly Point[],
offset?: Point,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
@ -115,8 +115,8 @@ export const mutateElbowArrow = (
true,
)
: endElement;
const startGlobalPoint = getGlobalPoint(
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
origEndGlobalPoint,
elementsMap,
@ -125,6 +125,7 @@ export const mutateElbowArrow = (
options?.isDragging,
);
const endGlobalPoint = getGlobalPoint(
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
origStartGlobalPoint,
elementsMap,
@ -132,21 +133,20 @@ export const mutateElbowArrow = (
hoveredEndElement,
options?.isDragging,
);
const startHeading = getBindPointHeading(
startGlobalPoint,
endGlobalPoint,
elementsMap,
hoveredStartElement,
origStartGlobalPoint,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
elementsMap,
hoveredEndElement,
origEndGlobalPoint,
);
// Calculate bounds needed for routing
const startPointBounds = [
startGlobalPoint[0] - 2,
startGlobalPoint[1] - 2,
@ -162,13 +162,25 @@ export const mutateElbowArrow = (
const startElementBounds = hoveredStartElement
? aabbForElement(
hoveredStartElement,
offsetFromHeading(startHeading, FIXED_BINDING_DISTANCE * 4, 1),
offsetFromHeading(
startHeading,
arrow.startArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
1,
),
)
: startPointBounds;
const endElementBounds = hoveredEndElement
? aabbForElement(
hoveredEndElement,
offsetFromHeading(endHeading, FIXED_BINDING_DISTANCE * 4, 1),
offsetFromHeading(
endHeading,
arrow.endArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
1,
),
)
: endPointBounds;
const boundsOverlap =
@ -209,7 +221,10 @@ export const mutateElbowArrow = (
startHeading,
!hoveredStartElement && !hoveredEndElement
? 0
: BASE_PADDING - FIXED_BINDING_DISTANCE * 4,
: BASE_PADDING -
(arrow.startArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2),
BASE_PADDING,
),
boundsOverlap
@ -222,7 +237,10 @@ export const mutateElbowArrow = (
endHeading,
!hoveredStartElement && !hoveredEndElement
? 0
: BASE_PADDING - FIXED_BINDING_DISTANCE * 4,
: BASE_PADDING -
(arrow.endArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2),
BASE_PADDING,
),
boundsOverlap,
@ -248,13 +266,6 @@ export const mutateElbowArrow = (
commonBounds,
);
// dynamicAABBs.forEach((bbox) => debugDrawBounds(bbox));
// [startElementBounds, endElementBounds]
// .filter((aabb) => aabb !== null)
// .forEach((bbox) => debugDrawBounds(bbox, "red"));
// debugDrawBounds(commonBounds, "cyan");
// grid.data.forEach((node) => node && debugDrawPoint(node.pos));
const startDongle =
startDonglePosition && pointToGridNode(startDonglePosition, grid);
const endDongle =
@ -296,7 +307,6 @@ export const mutateElbowArrow = (
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0,
roundness: null,
},
options?.informMutation,
);
@ -323,7 +333,15 @@ const offsetFromHeading = (
};
/**
* Routing algorithm.
* Routing algorithm based on the A* path search algorithm.
* @see https://www.geeksforgeeks.org/a-search-algorithm/
*
* Binary heap is used to optimize node lookup.
* See {@link calculateGrid} for the grid calculation details.
*
* Additional modifications added due to aesthetic route reasons:
* 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier)
* 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment
*/
const astar = (
start: Node,
@ -453,7 +471,8 @@ const m_dist = (a: Point, b: Point) =>
/**
* Create dynamically resizing, always touching
* bounding boxes for the given static bounds.
* bounding boxes having a minimum extent represented
* by the given static bounds.
*/
const generateDynamicAABBs = (
a: Bounds,
@ -610,8 +629,11 @@ const generateDynamicAABBs = (
};
/**
* Calculates the grid from which the node points are placed on
* based on the axis-aligned bounding boxes.
* Calculates the grid which is used as nodes at
* the grid line intersections by the A* algorithm.
*
* NOTE: This is not a uniform grid. It is built at
* various intersections of bounding boxes.
*/
const calculateGrid = (
aabbs: Bounds[],
@ -647,8 +669,8 @@ const calculateGrid = (
vertical.add(common[1]);
vertical.add(common[3]);
const _vertical = Array.from(vertical).sort((a, b) => a - b); // TODO: Do we need sorting?
const _horizontal = Array.from(horizontal).sort((a, b) => a - b); // TODO: Do we need sorting?
const _vertical = Array.from(vertical).sort((a, b) => a - b);
const _horizontal = Array.from(horizontal).sort((a, b) => a - b);
return {
row: _vertical.length,
@ -686,9 +708,6 @@ const getDonglePosition = (
return [bounds[0], point[1]];
};
export const crossProduct = (a: Point, b: Point): number =>
a[0] * b[1] - a[1] * b[0];
const estimateSegmentCount = (
start: Node,
end: Node,
@ -846,124 +865,6 @@ const pointToGridNode = (point: Point, grid: Grid): Node | null => {
return null;
};
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
export const headingForPointFromElement = (
element: ExcalidrawBindableElement,
aabb: Bounds,
point: Point,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
if (point[0] < element.x) {
return HEADING_LEFT;
} else if (point[1] < element.y) {
return HEADING_UP;
} else if (point[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (point[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const right = rotatePoint(
scalePointFromOrigin(
[element.x + element.width, element.y + element.height / 2],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y + element.height],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const left = rotatePoint(
scalePointFromOrigin(
[element.x, element.y + element.height / 2],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
if (PointInTriangle(point, top, right, midPoint)) {
return diamondHeading(top, right);
} else if (PointInTriangle(point, right, bottom, midPoint)) {
return diamondHeading(right, bottom);
} else if (PointInTriangle(point, bottom, left, midPoint)) {
return diamondHeading(bottom, left);
}
return diamondHeading(left, top);
}
const topLeft = scalePointFromOrigin(
[aabb[0], aabb[1]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const topRight = scalePointFromOrigin(
[aabb[2], aabb[1]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomLeft = scalePointFromOrigin(
[aabb[0], aabb[3]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomRight = scalePointFromOrigin(
[aabb[2], aabb[3]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
return PointInTriangle(point, topLeft, topRight, midPoint)
? HEADING_UP
: PointInTriangle(point, topRight, bottomRight, midPoint)
? HEADING_RIGHT
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
? HEADING_DOWN
: HEADING_LEFT;
};
const lineAngle = (a: Point, b: Point): number => {
const theta = Math.atan2(b[1] - a[1], b[0] - a[0]) * (180 / Math.PI);
return theta < 0 ? 360 + theta : theta;
};
const diamondHeading = (a: Point, b: Point) => {
const angle = lineAngle(a, b);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
return HEADING_RIGHT;
} else if (angle >= 135 && angle < 225) {
return HEADING_DOWN;
}
return HEADING_LEFT;
};
const commonAABB = (aabbs: Bounds[]): Bounds => [
Math.min(...aabbs.map((aabb) => aabb[0])),
Math.min(...aabbs.map((aabb) => aabb[1])),
@ -971,12 +872,7 @@ const commonAABB = (aabbs: Bounds[]): Bounds => [
Math.max(...aabbs.map((aabb) => aabb[3])),
];
/// UTILS
const getCenterForBounds = (bounds: Bounds): Point => [
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
];
/// #region Utils
const getBindableElementForId = (
id: string,
@ -1039,73 +935,6 @@ const neighborIndexToHeading = (idx: number): Heading => {
return HEADING_LEFT;
};
const getGlobalFixedPoints = (
arrow: ExcalidrawArrowElement,
elementsMap: ElementsMap,
) => {
const startElement =
arrow.startBinding && elementsMap.get(arrow.startBinding.elementId);
const endElement =
arrow.endBinding && elementsMap.get(arrow.endBinding.elementId);
const startPoint: Point =
startElement && arrow.startBinding
? rotatePoint(
[
startElement.x +
startElement.width * arrow.startBinding.fixedPoint[0],
startElement.y +
startElement.height * arrow.startBinding.fixedPoint[1],
],
[
startElement.x + startElement.width / 2,
startElement.y + startElement.height / 2,
],
startElement.angle,
)
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
const endPoint: Point =
endElement && arrow.endBinding
? rotatePoint(
[
endElement.x + endElement.width * arrow.endBinding.fixedPoint[0],
endElement.y + endElement.height * arrow.endBinding.fixedPoint[1],
],
[
endElement.x + endElement.width / 2,
endElement.y + endElement.height / 2,
],
endElement.angle,
)
: [
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
];
return [startPoint, endPoint];
};
export const getArrowLocalFixedPoints = (
arrow: ExcalidrawArrowElement,
elementsMap: ElementsMap,
) => {
const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap);
return [
LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap),
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
];
};
// const aabbsOverlapping = (a: Bounds, b: Bounds) =>
// pointInsideBounds([a[0], a[1]], b) ||
// pointInsideBounds([a[2], a[1]], b) ||
// pointInsideBounds([a[2], a[3]], b) ||
// pointInsideBounds([a[0], a[3]], b) ||
// pointInsideBounds([b[0], b[1]], a) ||
// pointInsideBounds([b[2], b[1]], a) ||
// pointInsideBounds([b[2], b[3]], a) ||
// pointInsideBounds([b[0], b[3]], a);
const getAllElementsMap = (
scene: Scene,
changedElements?: Map<string, OrderedExcalidrawElement>,
@ -1128,6 +957,7 @@ const getAllElements = (
: scene.getNonDeletedElements();
const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null,
initialPoint: Point,
otherPoint: Point,
elementsMap: NonDeletedSceneElementsMap,
@ -1135,48 +965,72 @@ const getGlobalPoint = (
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean,
): Point => {
if (isDragging && hoveredElement) {
const nonCornerPoint = isRectanguloidElement(hoveredElement)
? avoidRectangularCorner(hoveredElement, initialPoint)
: initialPoint;
const snapPoint =
hoveredElement &&
bindPointToSnapToElementOutline(
nonCornerPoint,
if (isDragging) {
if (hoveredElement) {
const snapPoint = getSnapPoint(
initialPoint,
otherPoint,
hoveredElement,
elementsMap,
);
return snapToMid(hoveredElement, snapPoint);
} else if (boundElement) {
return bindPointToSnapToElementOutline(
initialPoint,
otherPoint,
return snapToMid(hoveredElement, snapPoint);
}
return initialPoint;
}
if (boundElement) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0],
boundElement,
elementsMap,
);
// NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs(
distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) -
FIXED_BINDING_DISTANCE,
) > 0.01
? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap)
: fixedGlobalPoint;
}
return initialPoint;
};
// TODO: See if it can be merged with binding.ts: getHeadingForElbowArrowSnap()
const getSnapPoint = (
point: Point,
otherPoint: Point,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) =>
bindPointToSnapToElementOutline(
isRectanguloidElement(element)
? avoidRectangularCorner(element, point)
: point,
otherPoint,
element,
elementsMap,
);
const getBindPointHeading = (
point: Point,
otherPoint: Point,
elementsMap: NonDeletedSceneElementsMap,
hoveredElement?: ExcalidrawBindableElement | null,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: Point,
) =>
hoveredElement
? headingForPointFromElement(
getHeadingForElbowArrowSnap(
point,
otherPoint,
hoveredElement,
hoveredElement &&
aabbForElement(
hoveredElement,
aabbForElement(
hoveredElement,
Array(4).fill(
distanceToBindableElement(hoveredElement, point, elementsMap),
) as [number, number, number, number],
),
point,
)
: vectorToHeading(pointToVector(otherPoint, point));
Array(4).fill(
distanceToBindableElement(hoveredElement, point, elementsMap),
) as [number, number, number, number],
),
elementsMap,
origPoint,
);

View File

@ -1,4 +1,5 @@
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { getLineHeight } from "../fonts";
import { API } from "../tests/helpers/api";
import {
computeContainerDimensionForBoundText,
@ -8,7 +9,6 @@ import {
wrapText,
detectLineHeight,
getLineHeightInPx,
getDefaultLineHeight,
parseTokens,
} from "./textElement";
import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
@ -418,15 +418,15 @@ describe("Test getLineHeightInPx", () => {
describe("Test getDefaultLineHeight", () => {
it("should return line height using default font family when not passed", () => {
//@ts-ignore
expect(getDefaultLineHeight()).toBe(1.25);
expect(getLineHeight()).toBe(1.25);
});
it("should return line height using default font family for unknown font", () => {
const UNKNOWN_FONT = 5;
expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25);
expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
});
it("should return correct line height", () => {
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
});
});

View File

@ -6,7 +6,6 @@ import type {
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontFamilyValues,
FontString,
NonDeletedExcalidrawElement,
} from "./types";
@ -17,7 +16,6 @@ import {
BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
@ -30,7 +28,7 @@ import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import type { ExtractSetType, MakeBrand } from "../utility-types";
import type { ExtractSetType } from "../utility-types";
export const normalizeText = (text: string) => {
return (
@ -321,24 +319,6 @@ export const getLineHeightInPx = (
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
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
@ -349,29 +329,72 @@ export const getApproxMinLineHeight = (
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) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
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
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
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);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
});
return width;
};
@ -402,7 +425,11 @@ export const parseTokens = (text: string) => {
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
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
@ -412,7 +439,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font);
const spaceAdvanceWidth = getLineWidth(" ", font, true);
let currentLine = "";
let currentLineWidthTillNow = 0;
@ -427,13 +454,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
currentLine = "";
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
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
return; // continue
continue;
}
const words = parseTokens(originalLine);
@ -442,7 +470,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
let index = 0;
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
if (currentWordWidth === maxWidth) {
@ -454,7 +482,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
resetParams();
@ -463,20 +490,26 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const currentChar = String.fromCodePoint(
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);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
currentLineWidthTillNow = charAdvanceWidth;
} else {
currentLine += currentChar;
currentLine = line;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
push(currentLine);
resetParams();
// 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
} else if (!currentLine.endsWith("-")) {
currentLine += " ";
currentLineWidthTillNow += spaceWidth;
currentLineWidthTillNow += spaceAdvanceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
currentLineWidthTillNow = getLineWidth(
currentLine + word,
font,
true,
);
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
@ -512,7 +549,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
}
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
if (shouldAppendSpace) {
lines.push(currentLine.slice(0, -1));
} else {
@ -524,12 +561,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
}
}
}
if (currentLine.slice(-1) === " ") {
// only remove last trailing space which we have added when joining words
currentLine = currentLine.slice(0, -1);
push(currentLine);
}
});
}
return lines.join("\n");
};
@ -542,7 +581,7 @@ export const charWidth = (() => {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font);
const width = getLineWidth(char, font, true);
cachedCharWidth[font][ascii] = width;
}
@ -594,34 +633,9 @@ export const getMaxCharWidth = (font: FontString) => {
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) => {
return container?.boundElements?.length
? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
null
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
: null;
};
@ -866,79 +880,6 @@ export const isMeasureTextSupported = () => {
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 = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],

View File

@ -916,13 +916,13 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
editor.blur();
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
).toEqual(FONT_FAMILY["Comic Shanns"]);
//undo
Keyboard.withModifierKeys({ ctrl: true }, () => {
@ -930,7 +930,7 @@ describe("textWysiwyg", () => {
});
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Virgil);
).toEqual(FONT_FAMILY.Excalifont);
//redo
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
@ -938,7 +938,7 @@ describe("textWysiwyg", () => {
});
expect(
(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 () => {
@ -1330,14 +1330,14 @@ describe("textWysiwyg", () => {
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
).toEqual(FONT_FAMILY["Comic Shanns"]);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
fireEvent.click(screen.getByTitle(/Very large/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100);
});
it("should update line height when font family updated", async () => {
@ -1357,18 +1357,18 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
).toEqual(FONT_FAMILY["Comic Shanns"]);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.2);
).toEqual(1.25);
fireEvent.click(screen.getByTitle(/normal/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Helvetica);
).toEqual(FONT_FAMILY.Nunito);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.15);
).toEqual(1.35);
});
describe("should align correctly", () => {

View File

@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES } from "../constants";
import { CLASSES, isSafari } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -132,10 +132,15 @@ export const textWysiwyg = ({
updatedTextElement,
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 maxHeight = updatedTextElement.height;
let textElementWidth = updatedTextElement.width;
const textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
@ -177,9 +182,9 @@ export const textWysiwyg = ({
);
// autogrow container height if text exceeds
if (!isArrowElement(container) && textElementHeight > maxHeight) {
if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight = computeContainerDimensionForBoundText(
textElementHeight,
height,
container.type,
);
@ -190,10 +195,10 @@ export const textWysiwyg = ({
// is reached when text is removed
!isArrowElement(container) &&
container.height > originalContainerData.height &&
textElementHeight < maxHeight
height < maxHeight
) {
const targetContainerHeight = computeContainerDimensionForBoundText(
textElementHeight,
height,
container.type,
);
mutateElement(container, { height: targetContainerHeight });
@ -226,30 +231,41 @@ export const textWysiwyg = ({
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth);
width = Math.min(width, maxWidth);
} 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
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
font,
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: updatedTextElement.lineHeight,
width: `${textElementWidth}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
width: `${width}px`,
height: `${height}px`,
left: `${viewportX - padding}px`,
top: `${viewportY}px`,
transform: getTransform(
textElementWidth,
textElementHeight,
width,
height,
getTextElementAngle(updatedTextElement, container),
appState,
maxWidth,
editorMaxHeight,
),
padding: `0 ${padding}px`,
textAlign,
verticalAlign,
color: updatedTextElement.strokeColor,
@ -290,7 +306,6 @@ export const textWysiwyg = ({
minHeight: "1em",
backfaceVisibility: "hidden",
margin: 0,
padding: 0,
border: 0,
outline: 0,
resize: "none",
@ -336,7 +351,7 @@ export const textWysiwyg = ({
font,
getBoundTextMaxWidth(container, boundTextElement),
);
const width = getTextWidth(wrappedText, font);
const width = getTextWidth(wrappedText, font, true);
editable.style.width = `${width}px`;
}
};
@ -485,8 +500,10 @@ export const textWysiwyg = ({
};
const stopEvent = (event: Event) => {
event.preventDefault();
event.stopPropagation();
if (event.target instanceof HTMLCanvasElement) {
event.preventDefault();
event.stopPropagation();
}
};
// using a state variable instead of passing it to the handleSubmit callback
@ -579,46 +596,15 @@ export const textWysiwyg = ({
// in that same tick.
const target = event?.target;
const isTargetPickerTrigger =
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("active-color");
target.classList.contains("properties-trigger");
setTimeout(() => {
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
if (!isTargetPickerTrigger) {
if (!isPropertiesTrigger) {
editable.focus();
}
});
@ -626,16 +612,18 @@ export const textWysiwyg = ({
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const isTargetPickerTrigger =
event.target instanceof HTMLElement &&
event.target.classList.contains("active-color");
const target = event?.target;
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
if (
((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target)) ||
isTargetPickerTrigger
isPropertiesTrigger
) {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
@ -644,7 +632,7 @@ export const textWysiwyg = ({
window.addEventListener("blur", handleSubmit);
} else if (
event.target instanceof HTMLElement &&
!event.target.contains(editable) &&
event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
// so without introducing crazier hacks, nothing we can do
!isTestEnv()
@ -664,10 +652,10 @@ export const textWysiwyg = ({
// handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
updateWysiwygStyle();
const isColorPickerActive = !!document.activeElement?.closest(
".color-picker-content",
const isPopupOpened = !!document.activeElement?.closest(
".properties-content",
);
if (!isColorPickerActive) {
if (!isPopupOpened) {
editable.focus();
}
});

View File

@ -21,6 +21,9 @@ import type {
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
PointBinding,
FixedPointBinding,
} from "./types";
export const isInitializedImageElement = (
@ -106,7 +109,9 @@ export const isArrowElement = (
return element != null && element.type === "arrow";
};
export const isElbowArrow = (element?: ExcalidrawElement): boolean => {
export const isElbowArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawElbowArrowElement => {
return isArrowElement(element) && element.elbowed;
};
@ -160,6 +165,8 @@ export const isRectanguloidElement = (
return (
element != null &&
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
@ -281,3 +288,9 @@ export const getDefaultRoundnessTypeForElement = (
return null;
};
export const isFixedPointBinding = (
binding: PointBinding,
): binding is FixedPointBinding => {
return binding.fixedPoint != null;
};

View File

@ -6,7 +6,12 @@ import type {
THEME,
VERTICAL_ALIGN,
} from "../constants";
import type { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
import type {
MakeBrand,
MarkNonNullable,
Merge,
ValueOf,
} from "../utility-types";
import type { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line";
@ -228,13 +233,22 @@ export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"];
} & ExcalidrawTextElement;
export type FixedPoint = [number, number];
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: number;
gap: number;
fixedPoint: [number, number];
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint | null;
};
export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>;
export type Arrowhead =
| "arrow"
| "bar"
@ -263,6 +277,15 @@ export type ExcalidrawArrowElement = ExcalidrawLinearElement &
elbowed: boolean;
}>;
export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement,
{
elbowed: true;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
}
>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";

View File

@ -0,0 +1,159 @@
import { stringToBase64, toByteString } from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata";
export interface Font {
urls: URL[];
fontFace: FontFace;
getContent(): Promise<string>;
}
export const UNPKG_PROD_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
export class ExcalidrawFont implements Font {
public readonly urls: URL[];
public readonly fontFace: FontFace;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
this.urls = ExcalidrawFont.createUrls(uri);
const sources = this.urls
.map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
.join(", ");
this.fontFace = new FontFace(family, sources, {
display: "swap",
style: "normal",
weight: "400",
...descriptors,
});
}
/**
* Tries to fetch woff2 content, based on the registered urls.
* Returns last defined url in case of errors.
*
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
*/
public async getContent(): Promise<string> {
let i = 0;
const errorMessages = [];
while (i < this.urls.length) {
const url = this.urls[i];
if (url.protocol === "data:") {
// it's dataurl, the font is inlined as base64, no need to fetch
return url.toString();
}
try {
const response = await fetch(url, {
headers: {
Accept: "font/woff2",
},
});
if (response.ok) {
const mimeType = await response.headers.get("Content-Type");
const buffer = await response.arrayBuffer();
return `data:${mimeType};base64,${await stringToBase64(
await toByteString(buffer),
true,
)}`;
}
// response not ok, try to continue
errorMessages.push(
`"${url.toString()}" returned status "${response.status}"`,
);
} catch (e) {
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
}
i++;
}
console.error(
`Failed to fetch font "${
this.fontFace.family
}" from urls "${this.urls.toString()}`,
JSON.stringify(errorMessages, undefined, 2),
);
// in case of issues, at least return the last url as a content
// defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}
private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts
return [];
}
if (uri.startsWith("http") || uri.startsWith("data")) {
// one url for http imports or data url
return [new URL(uri)];
}
// 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(/^\/+/, "");
const urls: URL[] = [];
if (typeof window.EXCALIDRAW_ASSET_PATH === "string") {
const normalizedBaseUrl = this.normalizeBaseUrl(
window.EXCALIDRAW_ASSET_PATH,
);
urls.push(new URL(assetUrl, normalizedBaseUrl));
} else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) {
window.EXCALIDRAW_ASSET_PATH.forEach((path) => {
const normalizedBaseUrl = this.normalizeBaseUrl(path);
urls.push(new URL(assetUrl, normalizedBaseUrl));
});
}
// fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
return urls;
}
private static getFormat(url: URL) {
try {
const pathname = new URL(url).pathname;
const parts = pathname.split(".");
if (parts.length === 1) {
return "";
}
return `format('${parts.pop()}')`;
} catch (error) {
return "";
}
}
private static normalizeBaseUrl(baseUrl: string) {
let result = baseUrl;
// 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(result)) {
result = new URL(
result.replace(/^\.?\/+/, ""),
window?.location?.origin,
).toString();
}
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
result = `${result.replace(/\/+$/, "")}/`;
return result;
}
}

Binary file not shown.

View File

@ -0,0 +1,35 @@
/* Only UI fonts here, which are needed before the editor initializes. */
/* These cannot be dynamically prepended with `EXCALIDRAW_ASSET_PATH`. */
/* WARN: The following content is replaced during excalidraw-app build */
@font-face {
font-family: "Assistant";
src: url(./Assistant-Regular.woff2) format("woff2");
font-weight: 400;
style: normal;
display: swap;
}
@font-face {
font-family: "Assistant";
src: url(./Assistant-Medium.woff2) format("woff2");
font-weight: 500;
style: normal;
display: swap;
}
@font-face {
font-family: "Assistant";
src: url(./Assistant-SemiBold.woff2) format("woff2");
font-weight: 600;
style: normal;
display: swap;
}
@font-face {
font-family: "Assistant";
src: url(./Assistant-Bold.woff2) format("woff2");
font-weight: 700;
style: normal;
display: swap;
}

View File

@ -0,0 +1,359 @@
import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
FontFamilyValues,
} from "../element/types";
import { ShapeCache } from "../scene/ShapeCache";
import { isTextElement } from "../element";
import { getFontString } from "../utils";
import { FONT_FAMILY } from "../constants";
import {
LOCAL_FONT_PROTOCOL,
FONT_METADATA,
RANGES,
type FontMetadata,
} from "./metadata";
import { ExcalidrawFont, type Font } from "./ExcalidrawFont";
import { getContainerElement } from "../element/textElement";
import Virgil from "./assets/Virgil-Regular.woff2";
import Excalifont from "./assets/Excalifont-Regular.woff2";
import Cascadia from "./assets/CascadiaCode-Regular.woff2";
import ComicShanns from "./assets/ComicShanns-Regular.woff2";
import LiberationSans from "./assets/LiberationSans-Regular.woff2";
import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use
// a static member to reduce memory footprint
public static readonly loadedFontsCache = new Set<string>();
private static _registered:
| Map<
number,
{
metadata: FontMetadata;
fonts: Font[];
}
>
| undefined;
private static _initialized: boolean = false;
public static get registered() {
// lazy load the font registration
if (!Fonts._registered) {
Fonts._registered = Fonts.init();
} else if (!Fonts._initialized) {
// case when host app register fonts before they are lazy loaded
// don't override whatever has been previously registered
Fonts._registered = new Map([
...Fonts.init().entries(),
...Fonts._registered.entries(),
]);
}
return Fonts._registered;
}
public get registered() {
return Fonts.registered;
}
private readonly scene: Scene;
constructor({ scene }: { scene: Scene }) {
this.scene = scene;
}
/**
* if we load a (new) font, it's likely that text elements using it have
* already been rendered using a fallback font. Thus, we want invalidate
* their shapes and rerender. See #637.
*
* Invalidates text elements and rerenders scene, provided that at least one
* of the supplied fontFaces has not already been processed.
*/
public onLoaded = (fontFaces: readonly FontFace[]) => {
if (
// bail if all fonts with have been processed. We're checking just a
// subset of the font properties (though it should be enough), so it
// can technically bail on a false positive.
fontFaces.every((fontFace) => {
const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}-${fontFace.unicodeRange}`;
if (Fonts.loadedFontsCache.has(sig)) {
return true;
}
Fonts.loadedFontsCache.add(sig);
return false;
})
) {
return false;
}
let didUpdate = false;
const elementsMap = this.scene.getNonDeletedElementsMap();
for (const element of this.scene.getNonDeletedElements()) {
if (isTextElement(element)) {
didUpdate = true;
ShapeCache.delete(element);
const container = getContainerElement(element, elementsMap);
if (container) {
ShapeCache.delete(container);
}
}
}
if (didUpdate) {
this.scene.triggerUpdate();
}
};
/**
* Load font faces for a given scene and trigger scene update.
*/
public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFontFamilies();
const loaded = await Fonts.loadFontFaces(sceneFamilies);
this.onLoaded(loaded);
return loaded;
};
/**
* Gets all the font families for the given scene.
*/
public getSceneFontFamilies = () => {
return Fonts.getFontFamilies(this.scene.getNonDeletedElements());
};
/**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*/
public static loadFontsForElements = async (
elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => {
const fontFamilies = Fonts.getFontFamilies(elements);
return await Fonts.loadFontFaces(fontFamilies);
};
private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
) {
// add all registered font faces into the `document.fonts` (if not added already)
for (const { fonts, metadata } of Fonts.registered.values()) {
// skip registering font faces for local fonts (i.e. Helvetica)
if (metadata.local) {
continue;
}
for (const { fontFace } of fonts) {
if (!window.document.fonts.has(fontFace)) {
window.document.fonts.add(fontFace);
}
}
}
const loadedFontFaces = await Promise.all(
fontFamilies.map(async (fontFamily) => {
const fontString = getFontString({
fontFamily,
fontSize: 16,
});
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
if (!window.document.fonts.check(fontString)) {
try {
// WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
// we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
return await window.document.fonts.load(fontString);
} catch (e) {
// don't let it all fail if just one font fails to load
console.error(
`Failed to load font "${fontString}" from urls "${Fonts.registered
.get(fontFamily)
?.fonts.map((x) => x.urls)}"`,
e,
);
}
}
return Promise.resolve();
}),
);
return loadedFontFaces.flat().filter(Boolean) as FontFace[];
}
/**
* WARN: should be called just once on init, even across multiple instances.
*/
private static init() {
const fonts = {
registered: new Map<
ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fonts: Font[] }
>(),
};
// TODO: let's tweak this once we know how `register` will be exposed as part of the custom fonts API
const _register = register.bind(fonts);
_register("Virgil", FONT_METADATA[FONT_FAMILY.Virgil], {
uri: Virgil,
});
_register("Excalifont", FONT_METADATA[FONT_FAMILY.Excalifont], {
uri: Excalifont,
});
// keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
_register("Helvetica", FONT_METADATA[FONT_FAMILY.Helvetica], {
uri: LOCAL_FONT_PROTOCOL,
});
// used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
_register(
"Liberation Sans",
FONT_METADATA[FONT_FAMILY["Liberation Sans"]],
{
uri: LiberationSans,
},
);
_register("Cascadia", FONT_METADATA[FONT_FAMILY.Cascadia], {
uri: Cascadia,
});
_register("Comic Shanns", FONT_METADATA[FONT_FAMILY["Comic Shanns"]], {
uri: ComicShanns,
});
_register(
"Lilita One",
FONT_METADATA[FONT_FAMILY["Lilita One"]],
{ uri: LilitaLatinExt, descriptors: { unicodeRange: RANGES.LATIN_EXT } },
{ uri: LilitaLatin, descriptors: { unicodeRange: RANGES.LATIN } },
);
_register(
"Nunito",
FONT_METADATA[FONT_FAMILY.Nunito],
{
uri: NunitoCyrilicExt,
descriptors: { unicodeRange: RANGES.CYRILIC_EXT, weight: "500" },
},
{
uri: NunitoCyrilic,
descriptors: { unicodeRange: RANGES.CYRILIC, weight: "500" },
},
{
uri: NunitoVietnamese,
descriptors: { unicodeRange: RANGES.VIETNAMESE, weight: "500" },
},
{
uri: NunitoLatinExt,
descriptors: { unicodeRange: RANGES.LATIN_EXT, weight: "500" },
},
{
uri: NunitoLatin,
descriptors: { unicodeRange: RANGES.LATIN, weight: "500" },
},
);
Fonts._initialized = true;
return fonts.registered;
}
private static getFontFamilies(
elements: ReadonlyArray<ExcalidrawElement>,
): Array<ExcalidrawTextElement["fontFamily"]> {
return Array.from(
elements.reduce((families, element) => {
if (isTextElement(element)) {
families.add(element.fontFamily);
}
return families;
}, new Set<number>()),
);
}
}
/**
* Register a new font.
*
* @param family font family
* @param metadata font metadata
* @param params array of the rest of the FontFace parameters [uri: string, descriptors: FontFaceDescriptors?] ,
*/
function register(
this:
| Fonts
| {
registered: Map<
ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fonts: Font[] }
>;
},
family: string,
metadata: FontMetadata,
...params: Array<{ uri: string; descriptors?: FontFaceDescriptors }>
) {
// TODO: likely we will need to abandon number "id" in order to support custom fonts
const familyId = FONT_FAMILY[family as keyof typeof FONT_FAMILY];
const registeredFamily = this.registered.get(familyId);
if (!registeredFamily) {
this.registered.set(familyId, {
metadata,
fonts: params.map(
({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors),
),
});
}
return this.registered;
}
/**
* Calculates vertical offset for a text with alphabetic baseline.
*/
export const getVerticalOffset = (
fontFamily: ExcalidrawTextElement["fontFamily"],
fontSize: ExcalidrawTextElement["fontSize"],
lineHeightPx: number,
) => {
const { unitsPerEm, ascender, descender } =
Fonts.registered.get(fontFamily)?.metadata.metrics ||
FONT_METADATA[FONT_FAMILY.Virgil].metrics;
const fontSizeEm = fontSize / unitsPerEm;
const lineGap =
(lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2;
const verticalOffset = fontSizeEm * ascender + lineGap;
return verticalOffset;
};
/**
* Gets line height forr a selected family.
*/
export const getLineHeight = (fontFamily: FontFamilyValues) => {
const { lineHeight } =
Fonts.registered.get(fontFamily)?.metadata.metrics ||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
return lineHeight as ExcalidrawTextElement["lineHeight"];
};

View File

@ -0,0 +1,128 @@
import {
FontFamilyCodeIcon,
FontFamilyHeadingIcon,
FontFamilyNormalIcon,
FreedrawIcon,
} from "../components/icons";
import { FONT_FAMILY } from "../constants";
/**
* Encapsulates font metrics with additional font metadata.
* */
export interface FontMetadata {
/** for head & hhea metrics read the woff2 with https://fontdrop.info/ */
metrics: {
/** head.unitsPerEm metric */
unitsPerEm: 1000 | 1024 | 2048;
/** hhea.ascender metric */
ascender: number;
/** hhea.descender metric */
descender: number;
/** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */
lineHeight: number;
};
/** element to be displayed as an icon */
icon: JSX.Element;
/** flag to indicate a deprecated font */
deprecated?: true;
/** flag to indicate a server-side only font */
serverSide?: true;
/** flag to indiccate a local-only font */
local?: true;
}
export const FONT_METADATA: Record<number, FontMetadata> = {
[FONT_FAMILY.Excalifont]: {
metrics: {
unitsPerEm: 1000,
ascender: 886,
descender: -374,
lineHeight: 1.25,
},
icon: FreedrawIcon,
},
[FONT_FAMILY.Nunito]: {
metrics: {
unitsPerEm: 1000,
ascender: 1011,
descender: -353,
lineHeight: 1.35,
},
icon: FontFamilyNormalIcon,
},
[FONT_FAMILY["Lilita One"]]: {
metrics: {
unitsPerEm: 1000,
ascender: 923,
descender: -220,
lineHeight: 1.15,
},
icon: FontFamilyHeadingIcon,
},
[FONT_FAMILY["Comic Shanns"]]: {
metrics: {
unitsPerEm: 1000,
ascender: 750,
descender: -250,
lineHeight: 1.25,
},
icon: FontFamilyCodeIcon,
},
[FONT_FAMILY.Virgil]: {
metrics: {
unitsPerEm: 1000,
ascender: 886,
descender: -374,
lineHeight: 1.25,
},
icon: FreedrawIcon,
deprecated: true,
},
[FONT_FAMILY.Helvetica]: {
metrics: {
unitsPerEm: 2048,
ascender: 1577,
descender: -471,
lineHeight: 1.15,
},
icon: FontFamilyNormalIcon,
deprecated: true,
local: true,
},
[FONT_FAMILY.Cascadia]: {
metrics: {
unitsPerEm: 2048,
ascender: 1900,
descender: -480,
lineHeight: 1.2,
},
icon: FontFamilyCodeIcon,
deprecated: true,
},
[FONT_FAMILY["Liberation Sans"]]: {
metrics: {
unitsPerEm: 2048,
ascender: 1854,
descender: -434,
lineHeight: 1.15,
},
icon: FontFamilyNormalIcon,
serverSide: true,
},
};
/** Unicode ranges */
export const RANGES = {
LATIN:
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
LATIN_EXT:
"U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF",
CYRILIC_EXT:
"U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F",
CYRILIC: "U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116",
VIETNAMESE:
"U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB",
};
/** local protocol to skip the local font from registering or inlining */
export const LOCAL_FONT_PROTOCOL = "local:";

View File

@ -6,6 +6,9 @@ import type {
OrderedExcalidrawElement,
} from "./element/types";
import { InvalidFractionalIndexError } from "./errors";
import { hasBoundTextElement } from "./element/typeChecks";
import { getBoundTextElement } from "./element/textElement";
import { arrayToMap } from "./utils";
/**
* Envisioned relation between array order and fractional indices:
@ -30,17 +33,84 @@ import { InvalidFractionalIndexError } from "./errors";
* @throws `InvalidFractionalIndexError` if invalid index is detected.
*/
export const validateFractionalIndices = (
indices: (ExcalidrawElement["index"] | undefined)[],
elements: readonly ExcalidrawElement[],
{
shouldThrow = false,
includeBoundTextValidation = false,
ignoreLogs,
reconciliationContext,
}: {
shouldThrow: boolean;
includeBoundTextValidation: boolean;
ignoreLogs?: true;
reconciliationContext?: {
localElements: ReadonlyArray<ExcalidrawElement>;
remoteElements: ReadonlyArray<ExcalidrawElement>;
};
},
) => {
const errorMessages = [];
const stringifyElement = (element: ExcalidrawElement | void) =>
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
const indices = elements.map((x) => x.index);
for (const [i, index] of indices.entries()) {
const predecessorIndex = indices[i - 1];
const successorIndex = indices[i + 1];
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
throw new InvalidFractionalIndexError(
`Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`,
errorMessages.push(
`Fractional indices invariant has been compromised: "${stringifyElement(
elements[i - 1],
)}", "${stringifyElement(elements[i])}", "${stringifyElement(
elements[i + 1],
)}"`,
);
}
// disabled by default, as we don't fix it
if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
const container = elements[i];
const text = getBoundTextElement(container, arrayToMap(elements));
if (text && text.index! <= container.index!) {
errorMessages.push(
`Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
text,
)}", "${stringifyElement(container)}"`,
);
}
}
}
if (errorMessages.length) {
const error = new InvalidFractionalIndexError();
const additionalContext = [];
if (reconciliationContext) {
additionalContext.push("Additional reconciliation context:");
additionalContext.push(
reconciliationContext.localElements.map((x) => stringifyElement(x)),
);
additionalContext.push(
reconciliationContext.remoteElements.map((x) => stringifyElement(x)),
);
}
if (!ignoreLogs) {
// report just once and with the stacktrace
console.error(
errorMessages.join("\n\n"),
error.stack,
elements.map((x) => stringifyElement(x)),
...additionalContext,
);
}
if (shouldThrow) {
// if enabled, gather all the errors first, throw once
throw error;
}
}
};
@ -83,10 +153,19 @@ export const syncMovedIndices = (
// try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups);
const elementsCandidates = elements.map((x) =>
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
);
// ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices(
elements.map((x) => elementsUpdates.get(x)?.index || x.index),
elementsCandidates,
// we don't autofix invalid bound text indices, hence don't include it in the validation
{
includeBoundTextValidation: false,
shouldThrow: true,
ignoreLogs: true,
},
);
// split mutation so we don't end up in an incosistent state

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