Compare commits

..
Author SHA1 Message Date
zsviczian 64e43bf7c7 lint 2025-01-17 22:47:50 +00:00
zsviczian 9d24003517 testing 2025-01-17 22:39:06 +00:00
91ebf8b0ea feat: Elbow arrow segment fixing & positioning (#8952)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-01-17 18:07:03 +01:00
8551823da9 feat: update jotai (#9015)
* feat: update jotai in excalidraw package

* feat: update jotai in excalidraw-app

* fix: exports from excalidraw/jotai

* fix: use isolated react hooks

* test: use jotai provider in <Trans /> test

* remove unused package

* refactor & make safer

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-16 16:59:11 +01:00
David LuzarandGitHub ae6bee3403 feat: do not delete frame children on frame delete (#9011) 2025-01-14 21:08:25 +01:00
David LuzarandGitHub 46f42ef8d7 fix: arrow binding behaving unexpectedly on pointerup (#9010)
* fix: arrow binding behaving unexpectedly on pointerup

* update snaps
2025-01-14 19:36:47 +01:00
00b5b0a0ca feat: add action to wrap selected items in a frame (#9005)
* feat: add action to wrap selected items in a frame

* fix type

* select frame on wrap & refactor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-13 15:03:56 +00:00
YongJoon KimandGitHub c92f3bebf5 fix: change cursor by tool change immediately (#8212) 2025-01-09 14:26:12 +01:00
Marcel MrazandGitHub 2ac55067cd fix: package build fails on worker chunks (#8990) 2025-01-07 11:22:36 +00:00
David LuzarandGitHub 78ab12c7e6 fix: z-index clash in mobile UI (#8985) 2025-01-06 21:21:11 +01:00
David LuzarandGitHub f2f8219917 feat: reintroduce .excalidraw.png default when embedding scene (#8979) 2025-01-05 22:21:39 +01:00
한별andGitHub 12c39d1034 feat: add mimeTypes on file save (#8946) 2025-01-05 21:12:07 +00:00
d33e42e3a1 feat: add crowfoot to arrowheads (#8942)
* crowfoot many

* crowfoot one

* one or many

* add icons for crowfoot

* add crowfoot icons

* adjust arrowhead selection popover

* make options collapsible

* swap triangle and bar

* switch to radix popover

* put triangle outline in the first row

* align shadow with new design spec

* remove unused flag

* swap order

* tweak labels

* handle shift+tab

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Jakub Królak <108676707+j-krolak@users.noreply.github.com>
2025-01-05 21:50:24 +01:00
zsviczianandGitHub 3b9ffd9586 fix: elbow arrows do not work within frames (issue: #8964) (#8969)
check for !isFrameLikeElement
2025-01-05 21:47:20 +01:00
David LuzarandGitHub b63689c230 feat: make HTML attribute sanitization stricter (#8977)
* feat: make HTML attribute sanitization stricter

* fix double escape
2025-01-05 21:45:04 +01:00
David LuzarandGitHub c84babf574 feat: validate library install urls (#8976) 2025-01-05 17:10:55 +01:00
David LuzarandGitHub 36274f1f3e feat: cleanup svg export and move payload to <metadata> (#8975) 2025-01-05 16:53:05 +01:00
Aakansha DoshiandGitHub 798c795405 docs: add demo link for browser integration (#8956) 2024-12-27 14:39:08 +09:00
107eae3916 refactor: separate resizing logic from pointer (#8155)
* separate resizing logic for a single element

* replace resize logic in stats

* do not recompute width and height from points when they're already given

* correctly update linear elements' position when resized

* update snapshots

* lint

* simplify linear resizing logic

* fix initial scale for aspect ratio

* update tests for linear elements

* test typo

* separate pointer from resizing for multiple elements

* lint and simplify

* fix tests

* lint

* provide scene in param instead

* type

* refactor code

* fix floating in tests

* remove restrictions/checks on width & height

* update pointer to dimension to prevent regression

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-23 11:10:35 +01:00
zsviczianandGitHub 56fca30bd0 fix: normalizeSVG width and height from viewbox when size includes decimal points (#8939)
Update image.ts
2024-12-22 23:10:11 +01:00
Ryan DiandGitHub 1e3399eac8 fix: make arrow binding area adapt to zoom levels (#8927)
* make binding area adapt to zoom

* revert stroke color

* normalize binding gap

* reduce normalized gap
2024-12-22 22:55:50 +01:00
David LuzarandGitHub 873698a1a2 fix: robust state.editingFrame teardown (#8941) 2024-12-22 22:47:39 +01:00
Ryan DiandGitHub 606ac6c743 fix: regression on dragging a selected frame by its name (#8924)
fix hit element check for a selected frame's name
2024-12-22 22:47:21 +01:00
d99e4a23ca feat: use stats panel to crop (#8848)
* feat: use stats panel to crop

* fix: test flake

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-17 13:15:30 +01:00
Ryan DiandGitHub 551bae07a7 feat: snap when cropping as well (#8831)
* crop with snap

* make crop snap work with cmd as well

* turn off grid with cmd as well in crop
2024-12-16 18:31:33 +08:00
2af3221974 fix: right-click paste for images in clipboard (Issue #8826) (#8845)
* Fix right-click paste command for images (Issue #8826)

* Fix clipboard logic for multiple paste types

* fix: remove unused code

* refactor & robustness

* fix: creating paste event with image files

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-12-10 20:10:34 +00:00
117 changed files with 5991 additions and 3122 deletions
+15 -1
View File
@@ -3,6 +3,20 @@
"rules": {
"import/no-anonymous-default-export": "off",
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
"@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"disallowTypeAnnotations": false,
"fixStyle": "separate-type-imports"
}
],
"no-restricted-imports": [
"error",
{
"name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
}
]
}
}
@@ -12,7 +12,7 @@ import { Excalidraw } from "@excalidraw/excalidraw";
Throughout the documentation we use live, editable Excalidraw examples like the one shown below.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
For example, we're passing a `theme` prop to it based on the current color theme of the docs you're just reading.
:::
@@ -70,9 +70,9 @@ If you are using `pages router` then importing the wrapper dynamically would wor
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}>
<div style={{height:"500px", width:"500px"}}>
<Excalidraw />
</div>
</div>
);
};
export default ExcalidrawWrapper;
@@ -84,8 +84,8 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
@@ -97,7 +97,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
export default function Page() {
return (
<ExcalidrawWrapper />
<ExcalidrawWrapper />
);
}
```
@@ -108,7 +108,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
@@ -153,7 +153,7 @@ Since Vite removes env variables by default, you can update the vite config to e
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
:::
:::
## Browser
@@ -235,3 +235,5 @@ root.render(React.createElement(App));
</TabItem>
</Tabs>
You can try it out [here](https://codesandbox.io/p/sandbox/excalidraw-in-browser-tlqom?file=%2Findex.html%3A1%2C10).
+10 -7
View File
@@ -90,9 +90,13 @@ import {
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
import {
Provider,
useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import "./index.scss";
import type { ResolutionType } from "../packages/excalidraw/utility-types";
@@ -117,7 +121,7 @@ import {
share,
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
@@ -328,8 +332,7 @@ const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
@@ -1141,7 +1144,7 @@ const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}>
<Provider store={appJotaiStore}>
<ExcalidrawWrapper />
</Provider>
</TopErrorBoundary>
+36 -2
View File
@@ -1,3 +1,37 @@
import { unstable_createStore } from "jotai";
// eslint-disable-next-line no-restricted-imports
import {
atom,
Provider,
useAtom,
useAtomValue,
useSetAtom,
createStore,
type PrimitiveAtom,
} from "jotai";
import { useLayoutEffect } from "react";
export const appJotaiStore = unstable_createStore();
export const appJotaiStore = createStore();
export { atom, Provider, useAtom, useAtomValue, useSetAtom };
export const useAtomWithInitialValue = <
T extends unknown,
A extends PrimitiveAtom<T>,
>(
atom: A,
initialValue: T | (() => T),
) => {
const [value, setValue] = useAtom(atom);
useLayoutEffect(() => {
if (typeof initialValue === "function") {
// @ts-ignore
setValue(initialValue());
} else {
setValue(initialValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [value, setValue] as const;
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { useSetAtom } from "jotai";
import React from "react";
import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { useSetAtom } from "../app-jotai";
import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
@@ -1,5 +1,5 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { atom, useAtom } from "../app-jotai";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
+1 -2
View File
@@ -79,8 +79,7 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement"
import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import { appJotaiStore, atom } from "../app-jotai";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
+1 -1
View File
@@ -2,9 +2,9 @@ import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { warning } from "../../packages/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { atom } from "../app-jotai";
import "./CollabError.scss";
import { atom } from "jotai";
type ErrorIndicator = {
message: string | null;
+1 -1
View File
@@ -32,7 +32,7 @@
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"jotai": "2.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2",
+3 -3
View File
@@ -18,11 +18,11 @@ import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
import { atom, useAtom, useAtomValue } from "../app-jotai";
import "./ShareDialog.scss";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
+9 -10
View File
@@ -1,4 +1,3 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants";
@@ -6,18 +5,18 @@ import type { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const [appTheme, setAppTheme] = useState<Theme | "system">(() => {
return (
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT
);
});
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => {
@@ -66,5 +65,5 @@ export const useHandleAppTheme = () => {
}
}, [appTheme]);
return { editorTheme };
return { editorTheme, appTheme, setAppTheme };
};
@@ -7,7 +7,7 @@ import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import {
@@ -18,14 +18,12 @@ import {
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)),
@@ -33,48 +31,99 @@ const deleteSelectedElements = (
).map((el) => el.id),
);
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, elementsMap, bound.points);
}
});
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
let shouldSelectEditingGroup = true;
const nextElements = elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
mutateElement(bound, { points: bound.points });
}
});
}
return newElementWith(el, { isDeleted: true });
}
// if deleting a frame, remove the children from it and select them
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true;
return newElementWith(el, { frameId: null });
}
if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
let nextEditingGroupId = appState.editingGroupId;
// select next eligible element in currently editing group or supergroup
if (shouldSelectEditingGroup && appState.editingGroupId) {
const elems = getElementsInGroup(
nextElements,
appState.editingGroupId,
).filter((el) => !el.isDeleted);
if (elems.length > 1) {
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
} else {
nextEditingGroupId = null;
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
const lastElementInGroup = elems[0];
if (lastElementInGroup) {
const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
(groupId) => {
return groupId === appState.editingGroupId;
},
);
const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
if (superGroupId) {
const elems = getElementsInGroup(nextElements, superGroupId).filter(
(el) => !el.isDeleted,
);
if (elems.length > 1) {
nextEditingGroupId = superGroupId;
elems.forEach((el) => {
selectedElementIds[el.id] = true;
});
}
}
return newElementWith(el, { isDeleted: true });
}
}
}
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
return newElementWith(el, { isDeleted: true });
}
if (
isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId]
) {
return newElementWith(el, { isDeleted: true });
}
return el;
}),
return {
elements: nextElements,
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
...selectGroupsForSelectedElements(
{
selectedElementIds,
editingGroupId: nextEditingGroupId,
},
nextElements,
appState,
null,
),
},
};
};
@@ -157,11 +206,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
elementsMap,
);
LinearElementEditor.deletePoints(element, selectedPointsIndices);
return {
elements,
@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
},
startArrowhead: null,
endArrowhead: "arrow",
fixedSegments: null,
points: [
pointFrom(0, 0),
pointFrom(0, -35),
pointFrom(-90.9, -35),
pointFrom(-90.9, 204.9),
pointFrom(65.1, 204.9),
pointFrom(-90, -35),
pointFrom(-90, 204),
pointFrom(66, 204),
],
elbowed: true,
}),
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(100);
const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(250);
const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(250, 0);
});
});
+21 -19
View File
@@ -12,7 +12,6 @@ import { resizeMultipleElements } from "../element/resizeElements";
import type { AppClassProperties, AppState } from "../types";
import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import {
bindOrUnbindLinearElements,
isBindingEnabled,
@@ -25,8 +24,9 @@ import {
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement } from "../element/newElement";
import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -132,18 +132,25 @@ const flipElements = (
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
const { midX, midY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(
elementsMap,
selectedElements,
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
app.scene,
new Map(
Array.from(elementsMap.values()).map((element) => [
element.id,
deepCopyElement(element),
]),
),
{
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
},
);
bindOrUnbindLinearElements(
@@ -153,6 +160,7 @@ const flipElements = (
app.scene,
isBindingEnabled(appState),
[],
appState.zoom,
);
// ---------------------------------------------------------------------------
@@ -185,16 +193,10 @@ const flipElements = (
}),
);
elbowArrows.forEach((element) =>
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
),
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
// ---------------------------------------------------------------------------
+47 -2
View File
@@ -1,6 +1,6 @@
import { getNonDeletedElements } from "../element";
import { getCommonBounds, getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
import { getFrameChildren } from "../frame";
import { KEYS } from "../keys";
import type { AppClassProperties, AppState, UIAppState } from "../types";
@@ -10,6 +10,8 @@ import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store";
import { getSelectedElements } from "../scene";
import { newFrameElement } from "../element/newElement";
const isSingleFrameSelected = (
appState: UIAppState,
@@ -144,3 +146,46 @@ export const actionSetFrameAsActiveTool = register({
!event.altKey &&
event.key.toLocaleLowerCase() === KEYS.F,
});
export const actionWrapSelectionInFrame = register({
name: "wrapSelectionInFrame",
label: "labels.wrapSelectionInFrame",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
!selectedElements.some((element) => isFrameLikeElement(element))
);
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
const [x1, y1, x2, y2] = getCommonBounds(
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
const PADDING = 16;
const frame = newFrameElement({
x: x1 - PADDING,
y: y1 - PADDING,
width: x2 - x1 + PADDING * 2,
height: y2 - y1 + PADDING * 2,
});
const nextElements = addElementsToFrame(
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
);
return {
elements: nextElements,
appState: {
selectedElementIds: { [frame.id]: true },
},
storeAction: StoreAction.CAPTURE,
};
},
});
+196 -174
View File
@@ -53,6 +53,9 @@ import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
} from "../components/icons";
import {
ARROW_TYPE,
@@ -113,10 +116,9 @@ import {
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { pointFrom, vector } from "../../math";
import { pointFrom } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -1405,59 +1407,65 @@ const getArrowheadOptions = (flip: boolean) => {
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "e",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "dot",
text: t("labels.arrowhead_circle"),
keyBinding: null,
icon: <ArrowheadCircleIcon flip={flip} />,
showInPicker: false,
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "r",
icon: <ArrowheadCircleIcon flip={flip} />,
showInPicker: false,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: null,
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
showInPicker: false,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "t",
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "r",
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: null,
showInPicker: false,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "c",
},
{
value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "x",
},
{
value: "crowfoot_one_or_many",
text: t("labels.arrowhead_crowfoot_one_or_many"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
] as const;
};
@@ -1521,6 +1529,7 @@ export const actionChangeArrowhead = register({
appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
<IconPicker
label="arrowhead_end"
@@ -1537,6 +1546,7 @@ export const actionChangeArrowhead = register({
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
</div>
</fieldset>
@@ -1549,150 +1559,162 @@ export const actionChangeArrowType = register({
label: "Change arrow types",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
const newElements = changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
mutateElement(newElement, {
points: [finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
...(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,
),
},
}
: {}),
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
LinearElementEditor.updateEditorMidPointsCache(
newElement,
elementsMap,
app.state,
);
}
app.dismissLinearEditor();
return newElement;
});
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const newState = {
...appState,
currentItemArrowType: value,
};
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
// Change the arrow type and update any other state settings for
// the arrow.
const selectedId = appState.selectedLinearElement?.elementId;
if (selectedId) {
const selected = newElements.find((el) => el.id === selectedId);
if (selected) {
newState.selectedLinearElement = new LinearElementEditor(
selected as ExcalidrawLinearElement,
);
}
}
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
vector(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,
),
},
}
: {}),
},
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
return {
elements: newElements,
appState: newState,
storeAction: StoreAction.CAPTURE,
};
},
+2
View File
@@ -47,6 +47,7 @@ export type ShortcutName =
| "saveFileToDisk"
| "saveToActiveFile"
| "toggleShortcuts"
| "wrapSelectionInFrame"
>
| "saveScene"
| "imageExport"
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+2 -1
View File
@@ -137,7 +137,8 @@ export type ActionName =
| "searchMenu"
| "copyElementLink"
| "linkToElement"
| "cropEditor";
| "cropEditor"
| "wrapSelectionInFrame";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
+65 -35
View File
@@ -18,6 +18,8 @@ import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
import { createFile, isSupportedImageFileType } from "./data/blob";
import { ExcalidrawError } from "./errors";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -39,7 +41,7 @@ export interface ClipboardData {
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
type ParsedClipboardEvent =
type ParsedClipboardEventTextData =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
@@ -75,7 +77,7 @@ export const createPasteEvent = ({
types,
files,
}: {
types?: { [key in AllowedPasteMimeTypes]?: string };
types?: { [key in AllowedPasteMimeTypes]?: string | File };
files?: File[];
}) => {
if (!types && !files) {
@@ -88,6 +90,11 @@ export const createPasteEvent = ({
if (types) {
for (const [type, value] of Object.entries(types)) {
if (typeof value !== "string") {
files = files || [];
files.push(value);
continue;
}
try {
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
@@ -217,14 +224,14 @@ function parseHTMLTree(el: ChildNode) {
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html");
const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
try {
const doc = new DOMParser().parseFromString(html, "text/html");
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
const content = parseHTMLTree(doc.body);
@@ -238,34 +245,44 @@ const maybeParseHTMLPaste = (
return null;
};
/**
* Reads OS clipboard programmatically. May not work on all browsers.
* Will prompt user for permission if not granted.
*/
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
try {
if (navigator.clipboard?.readText) {
return { "text/plain": await navigator.clipboard?.readText() };
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
throw error;
}
}
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
let clipboardItems: ClipboardItems;
try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
try {
if (navigator.clipboard?.readText) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
const readText = await navigator.clipboard?.readText();
if (readText) {
return { [MIME_TYPES.text]: readText };
}
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
}
throw error;
}
@@ -276,10 +293,20 @@ export const readSystemClipboard = async () => {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
types[type] = await (await item.getType(type)).text();
} else if (isSupportedImageFileType(type)) {
const imageBlob = await item.getType(type);
const file = createFile(imageBlob, type, undefined);
types[type] = file;
} else {
throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
}
} catch (error: any) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
error instanceof ExcalidrawError
? error.message
: `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
@@ -296,10 +323,10 @@ export const readSystemClipboard = async () => {
/**
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEvent = async (
const parseClipboardEventTextData = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ParsedClipboardEvent> => {
): Promise<ParsedClipboardEventTextData> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
@@ -308,7 +335,7 @@ const parseClipboardEvent = async (
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value
.map((item) => item.value)
.join("\n")
@@ -319,7 +346,7 @@ const parseClipboardEvent = async (
return mixedContent;
}
const text = event.clipboardData?.getData("text/plain");
const text = event.clipboardData?.getData(MIME_TYPES.text);
return { type: "text", value: (text || "").trim() };
} catch {
@@ -328,13 +355,16 @@ const parseClipboardEvent = async (
};
/**
* Attempts to parse clipboard. Prefers system clipboard.
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
const parsedEventData = await parseClipboardEventTextData(
event,
isPlainPaste,
);
if (parsedEventData.type === "mixedContent") {
return {
@@ -423,8 +453,8 @@ export const copyTextToSystemClipboard = async (
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
try {
if (clipboardEvent) {
clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
throw new Error("Failed to setData on clipboardEvent");
}
return;
@@ -1,7 +1,6 @@
import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions";
import { t } from "../i18n";
import { jotaiScope } from "../jotai";
import { atom, useAtom } from "../editor-jotai";
import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog";
@@ -10,7 +9,6 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom,
jotaiScope,
);
const actionManager = useExcalidrawActionManager();
+253 -111
View File
@@ -165,6 +165,7 @@ import {
isTextBindableContainer,
isElbowArrow,
isFlowchartNodeElement,
isBindableElement,
} from "../element/typeChecks";
import type {
ExcalidrawBindableElement,
@@ -189,7 +190,6 @@ import type {
MagicGenerationData,
ExcalidrawNonSelectionElement,
ExcalidrawArrowElement,
NonDeletedSceneElementsMap,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
@@ -292,7 +292,6 @@ import {
getDateTime,
isShallowEqual,
arrayToMap,
toBrandedType,
} from "../utils";
import {
createSrcDoc,
@@ -378,9 +377,10 @@ import { actionPaste } from "../actions/actionClipboard";
import {
actionRemoveAllElementsFromFrame,
actionSelectAllElementsInFrame,
actionWrapSelectionInFrame,
} from "../actions/actionFrame";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai";
import { editorJotaiStore } from "../editor-jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { ImageSceneDataError } from "../errors";
import {
@@ -442,7 +442,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid";
import NewElementCanvas from "./canvases/NewElementCanvas";
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
import {
FlowChartCreator,
FlowChartNavigator,
@@ -1341,8 +1340,18 @@ class App extends React.Component<AppProps, AppState> {
_cache: new Map(),
};
private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
if (frame) {
mutateElement(frame, { name: frame.name?.trim() || null });
}
this.setState({ editingFrame: null });
};
private renderFrameNames = () => {
if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) {
if (this.state.editingFrame) {
this.resetEditingFrame(null);
}
return null;
}
@@ -1364,6 +1373,9 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
)
) {
if (this.state.editingFrame === f.id) {
this.resetEditingFrame(f);
}
// if frame not visible, don't render its name
return null;
}
@@ -1375,11 +1387,6 @@ class App extends React.Component<AppProps, AppState> {
const FRAME_NAME_EDIT_PADDING = 6;
const reset = () => {
mutateElement(f, { name: f.name?.trim() || null });
this.setState({ editingFrame: null });
};
let frameNameJSX;
const frameName = getFrameLikeTitle(f);
@@ -1397,13 +1404,13 @@ class App extends React.Component<AppProps, AppState> {
});
}}
onFocus={(e) => e.target.select()}
onBlur={() => reset()}
onBlur={() => this.resetEditingFrame(f)}
onKeyDown={(event) => {
// for some inexplicable reason, `onBlur` triggered on ESC
// does not reset `state.editingFrame` despite being called,
// and we need to reset it here as well
if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
reset();
this.resetEditingFrame(f);
}
}}
style={{
@@ -2068,7 +2075,7 @@ class App extends React.Component<AppProps, AppState> {
};
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
jotaiStore.set(activeEyeDropperAtom, {
editorJotaiStore.set(activeEyeDropperAtom, {
swapPreviewOnAlt: true,
colorPickerType:
type === "stroke" ? "elementStroke" : "elementBackground",
@@ -3175,45 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean;
fitToContent?: boolean;
}) => {
let elements = opts.elements.map((el, _, elements) => {
if (isElbowArrow(el)) {
const startEndElements = [
el.startBinding &&
elements.find((l) => l.id === el.startBinding?.elementId),
el.endBinding &&
elements.find((l) => l.id === el.endBinding?.elementId),
];
const startBinding = startEndElements[0] ? el.startBinding : null;
const endBinding = startEndElements[1] ? el.endBinding : null;
return {
...el,
...updateElbowArrow(
{
...el,
startBinding,
endBinding,
},
toBrandedType<NonDeletedSceneElementsMap>(
new Map(
startEndElements
.filter((x) => x != null)
.map(
(el) =>
[el!.id, el] as [
string,
Ordered<NonDeletedExcalidrawElement>,
],
),
),
),
[el.points[0], el.points[el.points.length - 1]],
),
};
}
return el;
});
elements = restoreElements(elements, null, undefined);
const elements = restoreElements(opts.elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2;
@@ -3312,7 +3281,7 @@ class App extends React.Component<AppProps, AppState> {
openSidebar:
this.state.openSidebar &&
this.device.editor.canFitSidebar &&
jotaiStore.get(isSidebarDockedAtom)
editorJotaiStore.get(isSidebarDockedAtom)
? this.state.openSidebar
: null,
...selectGroupsForSelectedElements(
@@ -4373,6 +4342,7 @@ class App extends React.Component<AppProps, AppState> {
(element) => element.id !== elbowArrow?.id || step !== 0,
),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
),
});
@@ -4538,7 +4508,7 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
) {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
}
// eye dropper
@@ -4588,6 +4558,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.zoom,
);
this.setState({ suggestedBindings: [] });
}
@@ -4680,7 +4651,10 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type === "hand") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (!isHoldingSpace) {
setCursorForShape(this.interactiveCanvas, this.state);
setCursorForShape(this.interactiveCanvas, {
...this.state,
activeTool: nextActiveTool,
});
}
if (isToolIcon(document.activeElement)) {
this.focusContainer();
@@ -5085,7 +5059,11 @@ class App extends React.Component<AppProps, AppState> {
isImageElement(element) ? 0 : this.getElementHitThreshold(),
);
return isPointInShape(pointFrom(x, y), selectionShape);
// if hitting the bounding box, return early
// but if not, we should check for other cases as well (e.g. frame name)
if (isPointInShape(pointFrom(x, y), selectionShape)) {
return true;
}
}
// take bound text element into consideration for hit collision as well
@@ -5342,6 +5320,11 @@ class App extends React.Component<AppProps, AppState> {
const selectedElements = this.scene.getSelectedElements(this.state);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (
event[KEYS.CTRL_OR_CMD] &&
@@ -5355,6 +5338,64 @@ class App extends React.Component<AppProps, AppState> {
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
return;
} else if (
this.state.selectedLinearElement &&
isElbowArrow(selectedElements[0])
) {
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
this.state.selectedLinearElement,
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const midPoint = hitCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
hitCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const nextIndex = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: null;
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement.pointerDownState,
segmentMidpoint: {
index: nextIndex,
value: hitCoords,
added: false,
},
},
segmentMidPointHoveredCoords: nextCoords,
},
});
return;
}
}
}
@@ -5365,11 +5406,6 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
const selectedGroupIds = getSelectedGroupIds(this.state);
if (selectedGroupIds.length > 0) {
@@ -5826,40 +5862,23 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
if (isElbowArrow(multiElement)) {
mutateElbowArrow(
multiElement,
this.scene.getNonDeletedElementsMap(),
[
// update last uncommitted point
mutateElement(
multiElement,
{
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
undefined,
undefined,
{
isDragging: true,
informMutation: false,
},
);
} else {
// update last uncommitted point
mutateElement(
multiElement,
{
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
},
false,
);
}
},
false,
{
isDragging: true,
},
);
// in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point
@@ -6025,7 +6044,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else {
} else if (!hitElement || !isElbowArrow(hitElement)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
@@ -6211,14 +6230,18 @@ class App extends React.Component<AppProps, AppState> {
this.state,
this.scene.getNonDeletedElementsMap(),
);
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
const isHoveringAPointHandle = isElbowArrow(element)
? hoverPointIndex === 0 ||
hoverPointIndex === element.points.length - 1
: hoverPointIndex >= 0;
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if (
// Ebow arrows can only be moved when unconnected
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
@@ -6268,7 +6291,7 @@ class App extends React.Component<AppProps, AppState> {
focus: false,
})),
}));
jotaiStore.set(searchItemInFocusAtom, null);
editorJotaiStore.set(searchItemInFocusAtom, null);
}
// since contextMenu options are potentially evaluated on each render,
@@ -6948,6 +6971,7 @@ class App extends React.Component<AppProps, AppState> {
if (
selectedElements.length === 1 &&
!this.state.editingLinearElement &&
!isElbowArrow(selectedElements[0]) &&
!(
this.state.selectedLinearElement &&
this.state.selectedLinearElement.hoverPointIndex !== -1
@@ -7389,6 +7413,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
this.setState({
@@ -7648,6 +7673,10 @@ class App extends React.Component<AppProps, AppState> {
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
fixedSegments:
this.state.currentItemArrowType === ARROW_TYPE.elbow
? []
: null,
})
: newLinearElement({
type: elementType,
@@ -7686,6 +7715,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(element),
);
@@ -7887,6 +7917,63 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (
this.state.selectedLinearElement &&
this.state.selectedLinearElement.elbowed &&
this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index
) {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
let index =
this.state.selectedLinearElement.pointerDownState.segmentMidpoint
.index;
if (index < 0) {
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: gridX, y: gridY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
index = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
}
const ret = LinearElementEditor.moveFixedSegment(
this.state.selectedLinearElement,
index,
gridX,
gridY,
this.scene.getNonDeletedElementsMap(),
);
flushSync(() => {
if (this.state.selectedLinearElement) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
pointerDownState: ret.pointerDownState,
},
});
}
});
return;
}
const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin;
this.lastPointerMoveCoords = pointerCoords;
@@ -8239,7 +8326,7 @@ class App extends React.Component<AppProps, AppState> {
// when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input
!this.state.editingFrame &&
if (!this.state.editingFrame) {
dragSelectedElements(
pointerDownState,
selectedElements,
@@ -8248,6 +8335,7 @@ class App extends React.Component<AppProps, AppState> {
snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
}
this.setState({
selectedElementsAreBeingDragged: true,
@@ -8264,6 +8352,7 @@ class App extends React.Component<AppProps, AppState> {
suggestedBindings: getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
),
});
}
@@ -8422,25 +8511,17 @@ class App extends React.Component<AppProps, AppState> {
},
false,
);
} else if (points.length > 1 && isElbowArrow(newElement)) {
mutateElbowArrow(
newElement,
elementsMap,
[...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
vector(0, 0),
undefined,
{
isDragging: true,
informMutation: false,
},
);
} else if (points.length === 2) {
} else if (
points.length === 2 ||
(points.length > 1 && isElbowArrow(newElement))
) {
mutateElement(
newElement,
{
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
},
false,
{ isDragging: true },
);
}
@@ -8635,6 +8716,24 @@ class App extends React.Component<AppProps, AppState> {
selectedElementsAreBeingDragged: false,
});
const elementsMap = this.scene.getNonDeletedElementsMap();
if (
pointerDownState.drag.hasOccurred &&
pointerDownState.hit?.element?.id
) {
const element = elementsMap.get(pointerDownState.hit.element.id);
if (isBindableElement(element)) {
// Renormalize elbow arrows when they are changed via indirect move
element.boundElements
?.filter((e) => e.type === "arrow")
.map((e) => elementsMap.get(e.id))
.filter((e) => isElbowArrow(e))
.forEach((e) => {
!!e && mutateElement(e, {}, true);
});
}
}
// Handle end of dragging a point of a linear element, might close a loop
// and sets binding element
if (this.state.editingLinearElement) {
@@ -8659,6 +8758,17 @@ class App extends React.Component<AppProps, AppState> {
}
}
} else if (this.state.selectedLinearElement) {
// Normalize elbow arrow points, remove close parallel segments
if (this.state.selectedLinearElement.elbowed) {
const element = LinearElementEditor.getElement(
this.state.selectedLinearElement.elementId,
this.scene.getNonDeletedElementsMap(),
);
if (element) {
mutateElement(element, {}, true);
}
}
if (
pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId
@@ -9098,10 +9208,10 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement?.elementId !== hitElement?.id &&
isLinearElement(hitElement)
) {
const selectedELements = this.scene.getSelectedElements(this.state);
const selectedElements = this.scene.getSelectedElements(this.state);
// set selectedLinearElement when no other element selected except
// the one we've hit
if (selectedELements.length === 1) {
if (selectedElements.length === 1) {
this.setState({
selectedLinearElement: new LinearElementEditor(hitElement),
});
@@ -9309,6 +9419,8 @@ class App extends React.Component<AppProps, AppState> {
}
if (
// not elbow midpoint dragged
!(hitElement && isElbowArrow(hitElement)) &&
// not dragged
!pointerDownState.drag.hasOccurred &&
// not resized
@@ -9396,6 +9508,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene,
isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.zoom,
);
}
@@ -9888,6 +10001,7 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
this.setState({
suggestedBindings:
@@ -9916,6 +10030,7 @@ class App extends React.Component<AppProps, AppState> {
coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isArrowElement(linearElement) && isElbowArrow(linearElement),
);
if (
@@ -10356,7 +10471,7 @@ class App extends React.Component<AppProps, AppState> {
const [x, y] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
this.getEffectiveGridSize(),
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const croppingElement = this.scene
@@ -10382,6 +10497,28 @@ class App extends React.Component<AppProps, AppState> {
image &&
!(image instanceof Promise)
) {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
const dragOffset = {
x: gridX - pointerDownState.originInGrid.x,
y: gridY - pointerDownState.originInGrid.y,
};
this.maybeCacheReferenceSnapPoints(event, [croppingElement]);
const { snapOffset, snapLines } = snapResizingElements(
[croppingElement],
[croppingAtStateStart],
this,
event,
dragOffset,
transformHandleType,
);
mutateElement(
croppingElement,
cropElement(
@@ -10389,8 +10526,8 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType,
image.naturalWidth,
image.naturalHeight,
x,
y,
x + snapOffset.x,
y + snapOffset.y,
event.shiftKey
? croppingAtStateStart.width / croppingAtStateStart.height
: undefined,
@@ -10410,6 +10547,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
isCropping: transformHandleType && transformHandleType !== "rotation",
snapLines,
});
}
@@ -10520,6 +10658,7 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType,
selectedElements,
this.scene.getElementsMapIncludingDeleted(),
this.scene,
shouldRotateWithDiscreteAngle(event),
shouldResizeFromCenter(event),
selectedElements.some((element) => isImageElement(element))
@@ -10534,6 +10673,7 @@ class App extends React.Component<AppProps, AppState> {
const suggestedBindings = getSuggestedBindingsForArrows(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
const elementsToHighlight = new Set<ExcalidrawElement>();
@@ -10609,8 +10749,10 @@ class App extends React.Component<AppProps, AppState> {
actionCut,
actionCopy,
actionPaste,
CONTEXT_MENU_SEPARATOR,
actionSelectAllElementsInFrame,
actionRemoveAllElementsFromFrame,
actionWrapSelectionInFrame,
CONTEXT_MENU_SEPARATOR,
actionToggleCropEditor,
CONTEXT_MENU_SEPARATOR,
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai";
import { useAtom } from "../../editor-jotai";
import { KEYS } from "../../keys";
import { activeEyeDropperAtom } from "../EyeDropper";
import clsx from "clsx";
@@ -57,10 +56,7 @@ export const ColorInput = ({
}
}, [activeSection]);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
useEffect(() => {
return () => {
@@ -5,7 +5,6 @@ import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useExcalidrawContainer } from "../App";
@@ -15,7 +14,7 @@ import PickerHeading from "./PickerHeading";
import { t } from "../../i18n";
import clsx from "clsx";
import { useRef } from "react";
import { jotaiScope } from "../../jotai";
import { useAtom } from "../../editor-jotai";
import { ColorInput } from "./ColorInput";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
@@ -76,10 +75,7 @@ const ColorPickerPopupContent = ({
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
const colorInputJSX = (
<div>
@@ -1,5 +1,5 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
@@ -5,7 +5,7 @@ import type { ExcalidrawElement } from "../../element/types";
import { ShadeList } from "./ShadeList";
import PickerColorList from "./PickerColorList";
import { useAtom } from "jotai";
import { useAtom } from "../../editor-jotai";
import { CustomColorList } from "./CustomColorList";
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import PickerHeading from "./PickerHeading";
@@ -1,5 +1,5 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
@@ -1,5 +1,5 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
@@ -1,7 +1,7 @@
import type { ExcalidrawElement } from "../../element/types";
import { atom } from "jotai";
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
import { atom } from "../../editor-jotai";
export const getColorNameAndShadeFromColor = ({
palette,
@@ -36,7 +36,7 @@ import {
getShortcutKey,
isWritableElement,
} from "../../utils";
import { atom, useAtom } from "jotai";
import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
import { deburr } from "../../deburr";
import type { MarkRequired } from "../../utility-types";
import { InlineIcon } from "../InlineIcon";
@@ -48,7 +48,6 @@ import {
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types";
import * as defaultItems from "./defaultCommandPaletteItems";
@@ -263,6 +262,7 @@ function CommandPaletteInner({
actionManager.actions.cut,
actionManager.actions.copy,
actionManager.actions.deleteSelectedElements,
actionManager.actions.wrapSelectionInFrame,
actionManager.actions.copyStyles,
actionManager.actions.pasteStyles,
actionManager.actions.bringToFront,
@@ -348,7 +348,7 @@ function CommandPaletteInner({
keywords: ["delete", "destroy"],
viewMode: false,
perform: () => {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
},
},
{
@@ -5,10 +5,9 @@ import { Dialog } from "./Dialog";
import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai";
import { useSetAtom } from "../editor-jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void;
@@ -27,7 +26,7 @@ const ConfirmDialog = (props: Props) => {
...rest
} = props;
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const { container } = useExcalidrawContainer();
return (
+2 -3
View File
@@ -11,9 +11,8 @@ import "./Dialog.scss";
import { Island } from "./Island";
import { Modal } from "./Modal";
import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai";
import { useSetAtom } from "../editor-jotai";
import { t } from "../i18n";
import { CloseIcon } from "./icons";
@@ -92,7 +91,7 @@ export const Dialog = (props: DialogProps) => {
}, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const onClose = () => {
setAppState({ openMenu: null });
@@ -1,4 +1,3 @@
import { atom } from "jotai";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { rgbToHex } from "../colors";
@@ -14,6 +13,7 @@ import { useStable } from "../hooks/useStable";
import "./EyeDropper.scss";
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
import type { ExcalidrawElement } from "../element/types";
import { atom } from "../editor-jotai";
export type EyeDropperProperties = {
keepOpenOnAlt: boolean;
@@ -15,7 +15,6 @@
top: var(--editor-container-padding);
right: var(--editor-container-padding);
bottom: var(--editor-container-padding);
z-index: 2;
}
.FixedSideContainer_side_top.zen-mode {
+11 -45
View File
@@ -1,19 +1,16 @@
@import "../css/variables.module.scss";
.excalidraw {
.picker-container {
display: inline-block;
box-sizing: border-box;
margin-right: 0.25rem;
}
.picker {
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
// ˇˇ yeah, i dunno, open to suggestions here :D
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
box-shadow: var(--shadow-island);
border-radius: 4px;
position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-container button,
@@ -55,47 +52,16 @@
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
}
.picker-triangle {
width: 0;
height: 0;
position: relative;
top: -10px;
:root[dir="ltr"] & {
left: 12px;
}
:root[dir="rtl"] & {
right: 12px;
}
z-index: 10;
&:before {
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -1px;
}
&:after {
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
}
}
.picker-content {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(3, auto);
grid-template-columns: repeat(4, auto);
grid-gap: 0.5rem;
border-radius: 4px;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-collapsible {
font-size: 0.75rem;
padding: 0.5rem 0;
}
.picker-keybinding {
+129 -100
View File
@@ -1,10 +1,22 @@
import React from "react";
import { Popover } from "./Popover";
import React, { useEffect } from "react";
import * as Popover from "@radix-ui/react-popover";
import { isArrowKey, KEYS } from "../keys";
import { getLanguage, t } from "../i18n";
import clsx from "clsx";
import Collapsible from "./Stats/Collapsible";
import { atom, useAtom } from "../editor-jotai";
import { useDevice } from "..";
import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys";
import { getLanguage } from "../i18n";
import clsx from "clsx";
const moreOptionsAtom = atom(false);
type Option<T> = {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
};
function Picker<T>({
options,
@@ -12,30 +24,16 @@ function Picker<T>({
label,
onChange,
onClose,
numberOfOptionsToAlwaysShow = options.length,
}: {
label: string;
value: T;
options: {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
options: readonly Option<T>[];
onChange: (value: T) => void;
onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) {
const rFirstItem = React.useRef<HTMLButtonElement>();
const rActiveItem = React.useRef<HTMLButtonElement>();
const rGallery = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// After the component is first mounted focus on first input
if (rActiveItem.current) {
rActiveItem.current.focus();
} else if (rGallery.current) {
rGallery.current.focus();
}
}, []);
const device = useDevice();
const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find(
@@ -44,28 +42,19 @@ function Picker<T>({
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
const index = options.indexOf(pressedOption);
(rGallery!.current!.children![index] as any).focus();
onChange(pressedOption.value);
event.preventDefault();
} else if (event.key === KEYS.TAB) {
// Tab navigation cycle through options. If the user tabs
// away from the picker, close the picker. We need to use
// a timeout here to let the stack clear before checking.
setTimeout(() => {
const active = rActiveItem.current;
const docActive = document.activeElement;
if (active !== docActive) {
onClose();
}
}, 0);
const index = options.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length
: (index + 1) % options.length;
onChange(options[nextIndex].value);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const { activeElement } = document;
const isRTL = getLanguage().rtl;
const index = Array.prototype.indexOf.call(
rGallery!.current!.children,
activeElement,
);
const index = options.findIndex((option) => option.value === value);
if (index !== -1) {
const length = options.length;
let nextIndex = index;
@@ -73,19 +62,26 @@ function Picker<T>({
switch (event.key) {
// Select the next option
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
case KEYS.ARROW_DOWN: {
nextIndex = (index + 1) % length;
break;
}
// Select the previous option
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
case KEYS.ARROW_UP: {
nextIndex = (length + index - 1) % length;
break;
// Go the next row
case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
// Go the previous row
case KEYS.ARROW_UP: {
nextIndex =
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
break;
}
}
(rGallery.current!.children![nextIndex] as any).focus();
onChange(options[nextIndex].value);
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -97,15 +93,26 @@ function Picker<T>({
event.stopPropagation();
};
return (
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
onKeyDown={handleKeyDown}
>
<div className="picker-content" ref={rGallery}>
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
setShowMoreOptions(true);
}
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => {
return (
<div className="picker-content">
{options.map((option, i) => (
<button
type="button"
@@ -113,7 +120,6 @@ function Picker<T>({
active: value === option.value,
})}
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(option.value);
}}
title={`${option.text} ${
@@ -122,16 +128,13 @@ function Picker<T>({
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined}
key={option.text}
ref={(el) => {
if (el && i === 0) {
rFirstItem.current = el;
ref={(ref) => {
if (value === option.value) {
// Use a timeout here to render focus properly
setTimeout(() => {
ref?.focus();
}, 0);
}
if (el && option.value === value) {
rActiveItem.current = el;
}
}}
onFocus={() => {
onChange(option.value);
}}
>
{option.icon}
@@ -141,7 +144,43 @@ function Picker<T>({
</button>
))}
</div>
</div>
);
};
return (
<Popover.Content
side={
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start"
sideOffset={12}
style={{ zIndex: "var(--zIndex-popup)" }}
onKeyDown={handleKeyDown}
>
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{moreOptions.length > 0 && (
<Collapsible
label={t("labels.more_options")}
open={showMoreOptions}
openTrigger={() => {
setShowMoreOptions((value) => !value);
}}
className="picker-collapsible"
>
{renderOptions(moreOptions)}
</Collapsible>
)}
</div>
</Popover.Content>
);
}
@@ -151,6 +190,7 @@ export function IconPicker<T>({
options,
onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: {
label: string;
value: T;
@@ -159,51 +199,40 @@ export function IconPicker<T>({
text: string;
icon: JSX.Element;
keyBinding: string | null;
showInPicker?: boolean;
}[];
onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) {
const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null);
const isRTL = getLanguage().rtl;
return (
<div>
<button
name={group}
type="button"
className={isActive ? "active" : ""}
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
>
{options.find((option) => option.value === value)?.icon}
</button>
<React.Suspense fallback="">
{isActive ? (
<>
<Popover
onCloseRequest={(event) =>
event.target !== rPickerButton.current && setActive(false)
}
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
>
<Picker
options={options.filter((opt) => opt.showInPicker !== false)}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
rPickerButton.current?.focus();
}}
/>
</Popover>
<div className="picker-triangle" />
</>
) : null}
</React.Suspense>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger
name={group}
type="button"
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""}
>
{options.find((option) => option.value === value)?.icon}
</Popover.Trigger>
{isActive && (
<Picker
options={options}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
}}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/>
)}
</Popover.Root>
</div>
);
}
+7 -9
View File
@@ -41,8 +41,7 @@ import { trackEvent } from "../analytics";
import { useDevice } from "./App";
import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import { useAtom, useAtomValue } from "../editor-jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
@@ -148,10 +147,9 @@ const LayerUI = ({
const device = useDevice();
const tunnels = useInitializeTunnels();
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
@@ -382,7 +380,7 @@ const LayerUI = ({
);
};
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
const layerUIJSX = (
<>
@@ -566,11 +564,11 @@ const LayerUI = ({
return (
<UIAppStateContext.Provider value={appState}>
<Provider scope={tunnels.jotaiScope}>
<TunnelsJotaiProvider>
<TunnelsContext.Provider value={tunnels}>
{layerUIJSX}
</TunnelsContext.Provider>
</Provider>
</TunnelsJotaiProvider>
</UIAppStateContext.Provider>
);
};
@@ -14,8 +14,7 @@ import type {
} from "../types";
import LibraryMenuItems from "./LibraryMenuItems";
import { trackEvent } from "../analytics";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { atom, useAtom } from "../editor-jotai";
import Spinner from "./Spinner";
import {
useApp,
@@ -61,7 +60,7 @@ export const LibraryMenuContent = ({
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => {
@@ -1,7 +1,7 @@
import { useCallback, useState } from "react";
import { t } from "../i18n";
import Trans from "./Trans";
import { jotaiScope } from "../jotai";
import { useAtom } from "../editor-jotai";
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
import { useApp, useExcalidrawSetAppState } from "./App";
import { saveLibraryAsJSON } from "../data/json";
@@ -17,7 +17,6 @@ import {
import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils";
import { useAtom } from "jotai";
import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog";
@@ -51,10 +50,9 @@ export const LibraryDropdownMenuButton: React.FC<{
appState,
className,
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [libraryItemsData] = useAtom(libraryItemsAtom);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom,
jotaiScope,
);
const renderRemoveLibAlert = () => {
@@ -286,7 +284,7 @@ export const LibraryDropdownMenu = ({
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [libraryItemsData] = useAtom(libraryItemsAtom);
const removeFromLibrary = async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
@@ -1,8 +1,7 @@
import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai";
import { useAtom } from "../../editor-jotai";
import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
@@ -23,7 +22,6 @@ const OverwriteConfirmDialog = Object.assign(
const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom,
jotaiScope,
);
if (!overwriteConfirmState.active) {
@@ -1,5 +1,4 @@
import { atom } from "jotai";
import { jotaiStore } from "../../jotai";
import { atom, editorJotaiStore } from "../../editor-jotai";
import type React from "react";
export type OverwriteConfirmState =
@@ -32,7 +31,7 @@ export async function openConfirmModal({
color: "danger" | "warning";
}) {
return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, {
editorJotaiStore.set(overwriteConfirmStateAtom, {
active: true,
onConfirm: () => resolve(true),
onClose: () => resolve(false),
@@ -11,8 +11,7 @@ import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys";
import clsx from "clsx";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { atom, useAtom } from "../editor-jotai";
import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random";
@@ -58,7 +57,7 @@ export const SearchMenu = () => {
const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
const [inputValue, setInputValue] = useAtom(searchQueryAtom);
const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false);
@@ -70,10 +69,7 @@ export const SearchMenu = () => {
const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom(
searchItemInFocusAtom,
jotaiScope,
);
const [focusIndex, setFocusIndex] = useAtom(searchItemInFocusAtom);
const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => {
@@ -8,8 +8,7 @@ import React, {
useCallback,
} from "react";
import { Island } from "../Island";
import { atom, useSetAtom } from "jotai";
import { jotaiScope } from "../../jotai";
import { atom, useSetAtom } from "../../editor-jotai";
import type { SidebarProps, SidebarPropsContextValue } from "./common";
import { SidebarPropsContext } from "./common";
import { SidebarHeader } from "./SidebarHeader";
@@ -58,7 +57,7 @@ export const SidebarInner = forwardRef(
const setAppState = useExcalidrawSetAppState();
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
useLayoutEffect(() => {
setIsSidebarDockedAtom(!!docked);
@@ -9,6 +9,7 @@ interface CollapsibleProps {
open: boolean;
openTrigger: () => void;
children: React.ReactNode;
className?: string;
}
const Collapsible = ({
@@ -16,6 +17,7 @@ const Collapsible = ({
open,
openTrigger,
children,
className,
}: CollapsibleProps) => {
return (
<>
@@ -26,6 +28,7 @@ const Collapsible = ({
justifyContent: "space-between",
alignItems: "center",
}}
className={className}
onClick={openTrigger}
>
{label}
@@ -1,10 +1,18 @@
import type { ExcalidrawElement } from "../../element/types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { resizeSingleElement } from "../../element/resizeElements";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { isImageElement } from "../../element/typeChecks";
import {
MINIMAL_CROP_SIZE,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
import { clamp, round } from "../../../math";
interface DimensionDragInputProps {
property: "width" | "height";
@@ -23,20 +31,124 @@ const handleDimensionChange: DragInputCallbackType<
> = ({
accumulatedChange,
originalElements,
originalElementsMap,
shouldKeepAspectRatio,
shouldChangeByStepSize,
nextValue,
property,
originalAppState,
instantChange,
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (origElement && latestElement) {
const keepAspectRatio =
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
const aspectRatio = origElement.width / origElement.height;
if (originalAppState.croppingElementId === origElement.id) {
const element = elementsMap.get(origElement.id);
if (!element || !isImageElement(element) || !element.crop) {
return;
}
const crop = element.crop;
let nextCrop = { ...crop };
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalToUncroppedWidthRatio = crop.naturalWidth / uncroppedWidth;
const naturalToUncroppedHeightRatio =
crop.naturalHeight / uncroppedHeight;
const MAX_POSSIBLE_WIDTH = isFlippedByX
? crop.width + crop.x
: crop.naturalWidth - crop.x;
const MAX_POSSIBLE_HEIGHT = isFlippedByY
? crop.height + crop.y
: crop.naturalHeight - crop.y;
const MIN_WIDTH = MINIMAL_CROP_SIZE * naturalToUncroppedWidthRatio;
const MIN_HEIGHT = MINIMAL_CROP_SIZE * naturalToUncroppedHeightRatio;
if (nextValue !== undefined) {
if (property === "width") {
const nextValueInNatural = nextValue * naturalToUncroppedWidthRatio;
const nextCropWidth = clamp(
nextValueInNatural,
MIN_WIDTH,
MAX_POSSIBLE_WIDTH,
);
nextCrop = {
...nextCrop,
width: nextCropWidth,
x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
};
} else if (property === "height") {
const nextValueInNatural = nextValue * naturalToUncroppedHeightRatio;
const nextCropHeight = clamp(
nextValueInNatural,
MIN_HEIGHT,
MAX_POSSIBLE_HEIGHT,
);
nextCrop = {
...nextCrop,
height: nextCropHeight,
y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
};
}
mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
return;
}
const changeInWidth = property === "width" ? instantChange : 0;
const changeInHeight = property === "height" ? instantChange : 0;
const nextCropWidth = clamp(
crop.width + changeInWidth,
MIN_WIDTH,
MAX_POSSIBLE_WIDTH,
);
const nextCropHeight = clamp(
crop.height + changeInHeight,
MIN_WIDTH,
MAX_POSSIBLE_HEIGHT,
);
nextCrop = {
...crop,
x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
width: nextCropWidth,
height: nextCropHeight,
};
mutateElement(element, {
crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
return;
}
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
@@ -55,14 +167,17 @@ const handleDimensionChange: DragInputCallbackType<
MIN_WIDTH_OR_HEIGHT,
);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
keepAspectRatio,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
return;
@@ -99,14 +214,17 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
keepAspectRatio,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
}
};
@@ -117,9 +235,25 @@ const DimensionDragInput = ({
scene,
appState,
}: DimensionDragInputProps) => {
const value =
Math.round((property === "width" ? element.width : element.height) * 100) /
100;
let value = round(property === "width" ? element.width : element.height, 2);
if (
appState.croppingElementId &&
appState.croppingElementId === element.id &&
isImageElement(element) &&
element.crop
) {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
if (property === "width") {
const ratio = uncroppedWidth / element.crop.naturalWidth;
value = round(element.crop.width * ratio, 2);
}
if (property === "height") {
const ratio = uncroppedHeight / element.crop.naturalHeight;
value = round(element.crop.height * ratio, 2);
}
}
return (
<DragInput
@@ -2,7 +2,10 @@ import { useMemo } from "react";
import { getCommonBounds, isTextElement } from "../../element";
import { updateBoundElements } from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import { rescalePointsInElement } from "../../element/resizeElements";
import {
rescalePointsInElement,
resizeSingleElement,
} from "../../element/resizeElements";
import {
getBoundTextElement,
handleBindTextResize,
@@ -17,7 +20,7 @@ import type { AppState } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import { getElementsInAtomicUnit } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { pointFrom, type GlobalPoint } from "../../../math";
@@ -150,7 +153,6 @@ const handleDimensionChange: DragInputCallbackType<
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
@@ -223,15 +225,17 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
elements,
scene,
false,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
},
);
}
}
@@ -324,14 +328,17 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
resizeSingleElement(
nextWidth,
nextHeight,
false,
latestElement,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
property === "width" ? "e" : "s",
{
shouldInformMutation: false,
},
);
}
}
@@ -237,6 +237,7 @@ const MultiPosition = ({
const [x1, y1] = getCommonBounds(elementsInUnit);
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
}
const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
@@ -4,7 +4,13 @@ import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math";
import { clamp, pointFrom, pointRotateRads, round } from "../../../math";
import { isImageElement } from "../../element/typeChecks";
import {
getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight,
} from "../../element/cropElement";
import { mutateElement } from "../../element/mutateElement";
interface PositionProps {
property: "x" | "y";
@@ -18,12 +24,14 @@ const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange,
instantChange,
originalElements,
originalElementsMap,
shouldChangeByStepSize,
nextValue,
property,
scene,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
@@ -38,6 +46,82 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.angle,
);
if (originalAppState.croppingElementId === origElement.id) {
const element = elementsMap.get(origElement.id);
if (!element || !isImageElement(element) || !element.crop) {
return;
}
const crop = element.crop;
let nextCrop = crop;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
if (nextValue !== undefined) {
if (property === "x") {
const nextValueInNatural =
nextValue * (crop.naturalWidth / uncroppedWidth);
if (isFlippedByX) {
nextCrop = {
...crop,
x: clamp(
crop.naturalWidth - nextValueInNatural - crop.width,
0,
crop.naturalWidth - crop.width,
),
};
} else {
nextCrop = {
...crop,
x: clamp(
nextValue * (crop.naturalWidth / uncroppedWidth),
0,
crop.naturalWidth - crop.width,
),
};
}
}
if (property === "y") {
nextCrop = {
...crop,
y: clamp(
nextValue * (crop.naturalHeight / uncroppedHeight),
0,
crop.naturalHeight - crop.height,
),
};
}
mutateElement(element, {
crop: nextCrop,
});
return;
}
const changeInX =
(property === "x" ? instantChange : 0) * (isFlippedByX ? -1 : 1);
const changeInY =
(property === "y" ? instantChange : 0) * (isFlippedByY ? -1 : 1);
nextCrop = {
...crop,
x: clamp(crop.x + changeInX, 0, crop.naturalWidth - crop.width),
y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height),
};
mutateElement(element, {
crop: nextCrop,
});
return;
}
if (nextValue !== undefined) {
const newTopLeftX = property === "x" ? nextValue : topLeftX;
const newTopLeftY = property === "y" ? nextValue : topLeftY;
@@ -97,8 +181,22 @@ const Position = ({
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
);
const value =
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
let value = round(property === "x" ? topLeftX : topLeftY, 2);
if (
appState.croppingElementId === element.id &&
isImageElement(element) &&
element.crop
) {
const flipAdjustedPosition = getFlipAdjustedCropPosition(element);
if (flipAdjustedPosition) {
value = round(
property === "x" ? flipAdjustedPosition.x : flipAdjustedPosition.y,
2,
);
}
}
return (
<StatsDragInput
+39 -3
View File
@@ -23,12 +23,14 @@ import Collapsible from "./Collapsible";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
import { isElbowArrow, isImageElement } from "../../element/typeChecks";
import CanvasGrid from "./CanvasGrid";
import clsx from "clsx";
import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math";
interface StatsProps {
app: AppClassProperties;
@@ -128,6 +130,13 @@ export const StatsInner = memo(
const multipleElements =
selectedElements.length > 1 ? selectedElements : null;
const cropMode =
appState.croppingElementId && isImageElement(singleElement);
const unCroppedDimension = cropMode
? getUncroppedWidthAndHeight(singleElement)
: null;
const [sceneDimension, setSceneDimension] = useState<{
width: number;
height: number;
@@ -244,8 +253,34 @@ export const StatsInner = memo(
<StatsRows>
{singleElement && (
<>
{cropMode && (
<StatsRow heading>
{t("labels.unCroppedDimension")}
</StatsRow>
)}
{appState.croppingElementId &&
isImageElement(singleElement) &&
unCroppedDimension && (
<StatsRow columns={2}>
<div>{t("stats.width")}</div>
<div>{round(unCroppedDimension.width, 2)}</div>
</StatsRow>
)}
{appState.croppingElementId &&
isImageElement(singleElement) &&
unCroppedDimension && (
<StatsRow columns={2}>
<div>{t("stats.height")}</div>
<div>{round(unCroppedDimension.height, 2)}</div>
</StatsRow>
)}
<StatsRow heading data-testid="stats-element-type">
{t(`element.${singleElement.type}`)}
{appState.croppingElementId
? t("labels.imageCropping")
: t(`element.${singleElement.type}`)}
</StatsRow>
<StatsRow>
@@ -387,7 +422,8 @@ export const StatsInner = memo(
prev.selectedElements === next.selectedElements &&
prev.appState.stats.panels === next.appState.stats.panels &&
prev.gridModeEnabled === next.gridModeEnabled &&
prev.appState.gridStep === next.appState.gridStep
prev.appState.gridStep === next.appState.gridStep &&
prev.appState.croppingElementId === next.appState.croppingElementId
);
},
);
+3 -101
View File
@@ -5,17 +5,7 @@ import {
updateBoundElements,
} from "../../element/binding";
import { mutateElement } from "../../element/mutateElement";
import {
measureFontSizeFromWidth,
rescalePointsInElement,
} from "../../element/resizeElements";
import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextMaxWidth,
handleBindTextResize,
} from "../../element/textElement";
import { getBoundTextElement } from "../../element/textElement";
import {
isFrameLikeElement,
isLinearElement,
@@ -34,7 +24,6 @@ import {
} from "../../groups";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
export type StatsInputProperty =
| "x"
@@ -121,95 +110,6 @@ export const newOrigin = (
};
};
export const resizeElement = (
nextWidth: number,
nextHeight: number,
keepAspectRatio: boolean,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
}
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
mutateElement(
latestElement,
{
...newOrigin(
latestElement.x,
latestElement.y,
latestElement.width,
latestElement.height,
nextWidth,
nextHeight,
latestElement.angle,
),
width: nextWidth,
height: nextHeight,
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) {
boundTextFont = {
fontSize: boundTextElement.fontSize,
};
if (keepAspectRatio) {
const updatedElement = {
...latestElement,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
boundTextFont = {
fontSize: nextFont?.size ?? boundTextElement.fontSize,
};
}
}
updateBoundElements(latestElement, elementsMap, {
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
};
export const moveElement = (
newTopLeftX: number,
newTopLeftY: number,
@@ -300,6 +200,7 @@ export const updateBindings = (
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
@@ -310,6 +211,7 @@ export const updateBindings = (
scene,
true,
[],
options?.zoom,
);
} else {
updateBoundElements(latestElement, elementsMap, options);
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss";
import { atom, useAtom } from "jotai";
import { atom, useAtom } from "../../editor-jotai";
import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
@@ -4,6 +4,7 @@ import fallbackLangData from "../locales/en.json";
import Trans from "./Trans";
import type { TranslationKeys } from "../i18n";
import { EditorJotaiProvider } from "../editor-jotai";
describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => {
@@ -17,7 +18,7 @@ describe("Test <Trans/>", () => {
};
const { getByTestId } = render(
<>
<EditorJotaiProvider>
<div data-testid="test1">
<Trans
i18nKey={"transTest.key1" as unknown as TranslationKeys}
@@ -51,7 +52,7 @@ describe("Test <Trans/>", () => {
connect-link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
</>,
</EditorJotaiProvider>,
);
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
@@ -1,6 +1,6 @@
import { atom, useAtom } from "jotai";
import React, { useLayoutEffect, useRef } from "react";
import { useTunnels } from "../../context/tunnels";
import { atom } from "../../editor-jotai";
export const withInternalFallback = <P,>(
componentName: string,
@@ -13,9 +13,11 @@ export const withInternalFallback = <P,>(
__fallback?: boolean;
}
> = (props) => {
const { jotaiScope } = useTunnels();
const {
tunnelsJotai: { useAtom },
} = useTunnels();
// for rerenders
const [, setCounter] = useAtom(renderAtom, jotaiScope);
const [, setCounter] = useAtom(renderAtom);
// for initial & subsequent renders. Tracked as component state
// due to excalidraw multi-instance scanerios.
const metaRef = useRef({
+48
View File
@@ -1352,6 +1352,54 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
),
);
export const ArrowheadCrowfootIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCrowfootOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
</g>,
{ width: 40, height: 20 },
),
);
export const FontSizeSmallIcon = createIcon(
<>
<g clipPath="url(#a)">
@@ -32,9 +32,8 @@ import {
actionToggleTheme,
} from "../../actions";
import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
import { useSetAtom } from "../../editor-jotai";
import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
@@ -189,10 +188,7 @@ Help.displayName = "Help";
export const ClearCanvas = () => {
const { t } = useI18n();
const setActiveConfirmDialog = useSetAtom(
activeConfirmDialogAtom,
jotaiScope,
);
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) {
+8 -2
View File
@@ -214,9 +214,9 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
@@ -230,6 +230,12 @@ export const MIME_TYPES = {
...IMAGE_MIME_TYPES,
} as const;
export const ALLOWED_PASTE_MIME_TYPES = [
MIME_TYPES.text,
MIME_TYPES.html,
...Object.values(IMAGE_MIME_TYPES),
] as const;
export const EXPORT_IMAGE_TYPES = {
png: "png",
svg: "svg",
+7 -2
View File
@@ -1,5 +1,6 @@
import React from "react";
import tunnel from "tunnel-rat";
import { createIsolation } from "jotai-scope";
export type Tunnel = ReturnType<typeof tunnel>;
@@ -14,13 +15,17 @@ type TunnelsContextValue = {
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
TTDDialogTriggerTunnel: Tunnel;
jotaiScope: symbol;
// this can be removed once we create jotai stores per each editor
// instance
tunnelsJotai: ReturnType<typeof createIsolation>;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
const tunnelsJotai = createIsolation();
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
@@ -34,7 +39,7 @@ export const useInitializeTunnels = () => {
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
TTDDialogTriggerTunnel: tunnel(),
jotaiScope: Symbol(),
tunnelsJotai,
};
}, []);
};
@@ -340,7 +340,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"elementId": "text-2",
"fixedPoint": null,
"focus": 0,
"gap": 205,
"gap": 14,
},
"fillStyle": "solid",
"frameId": null,
+7 -2
View File
@@ -5,6 +5,7 @@ import { clearElementsForExport } from "../element";
import type { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors";
import { calculateScrollCenter } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export";
import type { AppState, DataURL, LibraryItem } from "../types";
import type { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils";
@@ -47,7 +48,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
}
if (blob.type === MIME_TYPES.svg) {
try {
return (await import("./image")).decodeSvgMetadata({
return decodeSvgBase64Payload({
svg: contents,
});
} catch (error: any) {
@@ -106,11 +107,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
return type === "png" || type === "svg";
};
export const isSupportedImageFileType = (type: string | null | undefined) => {
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
};
export const isSupportedImageFile = (
blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {};
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
return isSupportedImageFileType(type);
};
export const loadSceneOrLibraryFromBlob = async (
+44 -15
View File
@@ -22,7 +22,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]);
return mimeTypes;
}, [] as string[]);
@@ -39,34 +38,62 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
mimeTypes,
multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
let isResolved = false;
let checkInterval: number | null = null;
// Increased delay for iOS to ensure file selection is complete
const CHECK_INTERVAL = 100; // 100ms
const MAX_CHECKS = 50; // 5 seconds total
let checkCount = 0;
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (isResolved) {
return;
}
if (input.files?.length) {
isResolved = true;
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
return true;
}
checkCount++;
if (checkCount >= MAX_CHECKS) {
scheduleRejection();
return true;
}
return false;
};
const focusHandler = () => {
// Start checking for files only after focus event
checkInterval = window.setInterval(() => {
if (checkForFile()) {
clearInterval(checkInterval!);
}
}, CHECK_INTERVAL);
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
};
requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
if (checkInterval) {
clearInterval(checkInterval);
}
scheduleRejection.cancel();
window.removeEventListener(EVENT.FOCUS, focusHandler);
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
if (rejectPromise && !isResolved) {
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError());
}
@@ -82,6 +109,7 @@ export const fileSave = (
name: string;
/** file extension */
extension: FILE_EXTENSION;
mimeTypes?: string[];
description: string;
/** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null;
@@ -93,10 +121,11 @@ export const fileSave = (
fileName: `${opts.name}.${opts.extension}`,
description: opts.description,
extensions: [`.${opts.extension}`],
mimeTypes: opts.mimeTypes,
},
opts.fileHandle,
);
};
export type { FileSystemHandle };
export { nativeFileSystemSupported };
export type { FileSystemHandle };
+1 -54
View File
@@ -1,7 +1,7 @@
import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { encode, decode } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob";
@@ -67,56 +67,3 @@ export const decodePngMetadata = async (blob: Blob) => {
}
throw new Error("INVALID");
};
// -----------------------------------------------------------------------------
// SVG
// -----------------------------------------------------------------------------
export const encodeSvgMetadata = ({ text }: { text: string }) => {
const base64 = stringToBase64(
JSON.stringify(encode({ text })),
true /* is already byte string */,
);
let metadata = "";
metadata += `<!-- payload-type:${MIME_TYPES.excalidraw} -->`;
metadata += `<!-- payload-version:2 -->`;
metadata += "<!-- payload-start -->";
metadata += base64;
metadata += "<!-- payload-end -->";
return metadata;
};
export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) {
throw new Error("INVALID");
}
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
const version = versionMatch?.[1] || "1";
const isByteString = version !== "1";
try {
const json = base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return json;
}
throw new Error("FAILED");
}
return decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};
+6 -5
View File
@@ -5,6 +5,7 @@ import {
import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FILENAME,
IMAGE_MIME_TYPES,
isFirefox,
MIME_TYPES,
} from "../constants";
@@ -15,8 +16,9 @@ import type {
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getElementsOverlappingFrame } from "../frame";
import { t } from "../i18n";
import { isSomeElementSelected, getSelectedElements } from "../scene";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export";
import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
@@ -25,7 +27,6 @@ import { canvasToBlob } from "./blob";
import type { FileSystemHandle } from "./filesystem";
import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json";
import { getElementsOverlappingFrame } from "../frame";
export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
@@ -130,6 +131,7 @@ export const exportCanvas = async (
description: "Export to SVG",
name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
mimeTypes: [IMAGE_MIME_TYPES.svg],
fileHandle,
},
);
@@ -168,9 +170,8 @@ export const exportCanvas = async (
return fileSave(blob, {
description: "Export to PNG",
name,
// FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
mimeTypes: [IMAGE_MIME_TYPES.png],
fileHandle,
});
} else if (type === "clipboard") {
+47 -7
View File
@@ -8,8 +8,7 @@ import type {
} from "../types";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
import { atom, editorJotaiStore } from "../editor-jotai";
import type { ExcalidrawElement } from "../element/types";
import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors";
@@ -35,6 +34,9 @@ import type { MaybePromise } from "../utility-types";
import { Emitter } from "../emitter";
import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url";
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */
@@ -188,13 +190,13 @@ class Library {
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, (s) => ({
editorJotaiStore.set(libraryItemsAtom, (s) => ({
status: "loading",
libraryItems: this.currLibraryItems,
isInitialized: s.isInitialized,
}));
} else {
jotaiStore.set(libraryItemsAtom, {
editorJotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.currLibraryItems,
isInitialized: true,
@@ -222,7 +224,7 @@ class Library {
destroy = () => {
this.updateQueue = [];
this.currLibraryItems = [];
jotaiStore.set(libraryItemSvgsCache, new Map());
editorJotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, {
// status: "loading",
@@ -467,6 +469,28 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements;
};
const validateLibraryUrl = (
libraryUrl: string,
/**
* If supplied, takes precedence over the default whitelist.
* Return `true` if the URL is valid.
*/
validator?: (libraryUrl: string) => boolean,
): boolean => {
if (
validator
? validator(libraryUrl)
: ALLOWED_LIBRARY_HOSTNAMES.includes(
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
)
) {
return true;
}
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
throw new Error("Invalid or disallowed library URL");
};
export const parseLibraryTokensFromUrl = () => {
const libraryUrl =
// current
@@ -608,6 +632,11 @@ const persistLibraryUpdate = async (
export const useHandleLibrary = (
opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
/**
* Return `true` if the library install url should be allowed.
* If not supplied, only the excalidraw.com base domain is allowed.
*/
validateLibraryUrl?: (libraryUrl: string) => boolean;
} & (
| {
/** @deprecated we recommend using `opts.adapter` instead */
@@ -650,7 +679,13 @@ export const useHandleLibrary = (
}) => {
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
try {
const request = await fetch(decodeURIComponent(libraryUrl));
libraryUrl = decodeURIComponent(libraryUrl);
libraryUrl = toValidURL(libraryUrl);
validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
const request = await fetch(libraryUrl);
const blob = await request.blob();
resolve(blob);
} catch (error: any) {
@@ -678,7 +713,12 @@ export const useHandleLibrary = (
defaultStatus: "published",
openLibraryMenu: true,
});
} catch (error) {
} catch (error: any) {
excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
throw error;
} finally {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
+41 -12
View File
@@ -1,5 +1,6 @@
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
@@ -101,23 +102,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (
element: ExcalidrawLinearElement,
const repairBinding = <T extends ExcalidrawLinearElement>(
element: T,
binding: PointBinding | FixedPointBinding | null,
): PointBinding | FixedPointBinding | null => {
): T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null => {
if (!binding) {
return null;
}
return {
...binding,
focus: binding.focus || 0,
...(isElbowArrow(element) && isFixedPointBinding(binding)
const focus = binding.focus || 0;
if (isElbowArrow(element)) {
const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"]
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
? {
...binding,
focus,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: {}),
};
: null;
return fixedPointBinding;
}
return {
...binding,
focus,
} as T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null;
};
const restoreElementWithProperties = <
@@ -308,8 +324,7 @@ const restoreElement = (
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
const base = {
type: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
@@ -321,7 +336,20 @@ const restoreElement = (
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
});
} as const;
// TODO: Separate arrow from linear element
return isElbowArrow(element)
? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
...base,
elbowed: true,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
fixedSegments: element.fixedSegments,
startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial,
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
}
// generic elements
@@ -639,6 +667,7 @@ export const restoreAppState = (
gridStep: getNormalizedGridStep(
isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
),
editingFrame: null,
};
};
+1 -1
View File
@@ -779,7 +779,7 @@ describe("Test Transform", () => {
elementId: "rect-1",
fixedPoint: null,
focus: 0,
gap: 205,
gap: 14,
});
expect(rect.boundElements).toStrictEqual([
{
+2 -1
View File
@@ -25,6 +25,7 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>");
expect(normalizeLink("<test>")).toBe("&lt;test&gt;");
expect(normalizeLink("test&")).toBe("test&amp;");
});
});
+1 -4
View File
@@ -1,8 +1,5 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const sanitizeHTMLAttribute = (html: string) => {
return html.replace(/"/g, "&quot;");
};
import { sanitizeHTMLAttribute } from "../utils";
export const normalizeLink = (link: string) => {
link = link.trim();
+13
View File
@@ -0,0 +1,13 @@
// eslint-disable-next-line no-restricted-imports
import { atom, createStore, type PrimitiveAtom } from "jotai";
import { createIsolation } from "jotai-scope";
const jotai = createIsolation();
export { atom, PrimitiveAtom };
export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
export const EditorJotaiProvider: ReturnType<
typeof createIsolation
>["Provider"] = jotai.Provider;
export const editorJotaiStore: ReturnType<typeof createStore> = createStore();
+83 -31
View File
@@ -97,6 +97,8 @@ export const isBindingEnabled = (appState: AppState): boolean => {
};
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = (
scene: Scene,
@@ -213,6 +215,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
@@ -223,7 +226,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap)
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
@@ -235,12 +238,14 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement,
edge as "start" | "end",
elementsMap,
zoom,
),
);
@@ -250,6 +255,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
draggingPoints: readonly number[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0;
const endIdx = selectedElement.points.length - 1;
@@ -262,6 +268,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start",
elementsMap,
elements,
zoom,
)
: null // If binding is disabled and start is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
@@ -270,6 +277,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"start",
elementsMap,
elements,
zoom,
);
const end = endDragged
? isBindingEnabled
@@ -278,6 +286,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end",
elementsMap,
elements,
zoom,
)
: null // If binding is disabled and end is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind
@@ -286,6 +295,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
"end",
elementsMap,
elements,
zoom,
);
return [start, end];
@@ -296,10 +306,12 @@ const getBindingStrategyForDraggingArrowOrJoints = (
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
isBindingEnabled: boolean,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement,
elementsMap,
zoom,
);
const start = startIsClose
? isBindingEnabled
@@ -308,6 +320,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"start",
elementsMap,
elements,
zoom,
)
: null
: null;
@@ -318,6 +331,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
"end",
elementsMap,
elements,
zoom,
)
: null
: null;
@@ -332,6 +346,7 @@ export const bindOrUnbindLinearElements = (
scene: Scene,
isBindingEnabled: boolean,
draggingPoints: readonly number[] | null,
zoom?: AppState["zoom"],
): void => {
selectedElements.forEach((selectedElement) => {
const [start, end] = draggingPoints?.length
@@ -342,6 +357,7 @@ export const bindOrUnbindLinearElements = (
draggingPoints ?? [],
elementsMap,
elements,
zoom,
)
: // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints(
@@ -349,6 +365,7 @@ export const bindOrUnbindLinearElements = (
elementsMap,
elements,
isBindingEnabled,
zoom,
);
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
@@ -358,6 +375,7 @@ export const bindOrUnbindLinearElements = (
export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) {
@@ -368,7 +386,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements
.filter(isLinearElement)
.flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom),
)
.filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -406,6 +424,7 @@ export const maybeBindLinearElement = (
pointerCoords,
elements,
elementsMap,
appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement),
);
@@ -422,6 +441,26 @@ export const maybeBindLinearElement = (
}
};
const normalizePointBinding = (
binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement,
) => {
let gap = binding.gap;
const maxGap = maxBindingGap(
hoveredElement,
hoveredElement.width,
hoveredElement.height,
);
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
@@ -433,11 +472,14 @@ export const bindLinearElement = (
}
const binding: PointBinding = {
elementId: hoveredElement.id,
...calculateFocusAndGap(
linearElement,
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
startOrEnd,
elementsMap,
),
...(isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding(
@@ -514,6 +556,7 @@ export const getHoveredElementForBinding = (
},
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition(
@@ -524,11 +567,13 @@ export const getHoveredElementForBinding = (
element,
pointerCoords,
elementsMap,
zoom,
// disable fullshape snapping for frame elements so we
// can bind to frame children
fullShape && !isFrameLikeElement(element),
),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@@ -580,7 +625,7 @@ export const updateBoundElements = (
changedElements?: Map<string, OrderedExcalidrawElement>;
},
) => {
const { newSize, simultaneouslyUpdated, changedElements } = options ?? {};
const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
@@ -614,7 +659,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings);
mutateElement(element, bindings, true);
return;
}
@@ -656,22 +701,14 @@ export const updateBoundElements = (
}> => update !== null,
);
LinearElementEditor.movePoints(
element,
updates,
elementsMap,
{
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
},
{
changedElements,
},
);
LinearElementEditor.movePoints(element, updates, {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
});
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) {
@@ -703,6 +740,7 @@ export const getHeadingForElbowArrowSnap = (
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -714,6 +752,7 @@ export const getHeadingForElbowArrowSnap = (
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) {
@@ -728,15 +767,14 @@ export const getHeadingForElbowArrowSnap = (
);
}
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
return pointHeading;
return headingForPointFromElement(bindableElement, aabb, p);
};
const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(
bindableElement,
@@ -747,6 +785,7 @@ const getDistanceForBinding = (
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
return distance > bindDistance ? null : distance;
@@ -1174,11 +1213,13 @@ const getElligibleElementForBindingElement = (
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding(
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
elements,
elementsMap,
zoom,
);
};
@@ -1341,9 +1382,11 @@ export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
): boolean => {
const threshold = maxBindingGap(element, element.width, element.height);
const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape(pointFrom(x, y), shape, threshold) ||
@@ -1356,12 +1399,21 @@ export const maxBindingGap = (
element: ExcalidrawElement,
elementWidth: number,
elementHeight: number,
zoom?: AppState["zoom"],
): number => {
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
// Aligns diamonds with rectangles
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
// We make the bindable boundary bigger for bigger elements
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
return Math.max(
16,
// bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
);
};
export const distanceToBindableElement = (
@@ -2218,7 +2270,7 @@ export const getGlobalFixedPointForBindableElement = (
);
};
const getGlobalFixedPoints = (
export const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
): [GlobalPoint, GlobalPoint] => {
+19
View File
@@ -556,6 +556,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond":
case "diamond_outline":
return 12;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
default:
return 15;
}
@@ -669,6 +673,21 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead);
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(angle),
);
return [xs, ys, x3, y3, x4, y4];
}
// Return points
const [x3, y3] = pointRotateRads(
pointFrom(xs, ys),
+39 -1
View File
@@ -26,7 +26,7 @@ import {
getResizedElementAbsoluteCoords,
} from "./bounds";
const MINIMAL_CROP_SIZE = 10;
export const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
@@ -585,3 +585,41 @@ const adjustCropPosition = (
cropY,
};
};
export const getFlipAdjustedCropPosition = (
element: ExcalidrawImageElement,
natural = false,
) => {
const crop = element.crop;
if (!crop) {
return null;
}
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let cropX = crop.x;
let cropY = crop.y;
if (isFlippedByX) {
cropX = crop.naturalWidth - crop.width - crop.x;
}
if (isFlippedByY) {
cropY = crop.naturalHeight - crop.height - crop.y;
}
if (natural) {
return {
x: cropX,
y: cropY,
};
}
const { width, height } = getUncroppedWidthAndHeight(element);
return {
x: cropX / (crop.naturalWidth / width),
y: cropY / (crop.naturalHeight / height),
};
};
+18 -9
View File
@@ -42,9 +42,20 @@ export const dragSelectedElements = (
return;
}
const selectedElements = _selectedElements.filter(
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
);
const selectedElements = _selectedElements.filter((element) => {
if (isElbowArrow(element) && element.startBinding && element.endBinding) {
const startElement = _selectedElements.find(
(el) => el.id === element.startBinding?.elementId,
);
const endElement = _selectedElements.find(
(el) => el.id === element.endBinding?.elementId,
);
return startElement && endElement;
}
return true;
});
// 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
@@ -78,10 +89,8 @@ export const dragSelectedElements = (
elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset);
if (
if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render
!isArrowElement(element)
) {
const textElement = getBoundTextElement(
element,
scene.getNonDeletedElementsMap(),
@@ -89,10 +98,10 @@ export const dragSelectedElements = (
if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset);
}
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
}
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
});
};
@@ -9,20 +9,121 @@ import {
render,
} from "../tests/test-utils";
import { bindLinearElement } from "./binding";
import { Excalidraw } from "../index";
import { mutateElbowArrow } from "./routing";
import { Excalidraw, mutateElement } from "../index";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
} from "./types";
import { ARROW_TYPE } from "../constants";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
const { h } = window;
const mouse = new Pointer("mouse");
describe("elbow arrow segment move", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can move the second segment of a fully connected elbow arrow", () => {
UI.createElement("rectangle", {
x: -100,
y: -50,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 200,
y: 150,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(200, 200);
mouse.click();
mouse.reset();
mouse.moveTo(100, 100);
mouse.down();
mouse.moveTo(115, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
expect(arrow.fixedSegments?.length).toBe(1);
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
mouse.reset();
mouse.moveTo(105, 74.275);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
});
it("can move the second segment of an unconnected elbow arrow", () => {
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(250, 200);
mouse.click();
mouse.reset();
mouse.moveTo(125, 100);
mouse.down();
mouse.moveTo(130, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[130, 0],
[130, 200],
[250, 200],
]);
mouse.reset();
mouse.moveTo(130, 100);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[125, 0],
[125, 200],
[250, 200],
]);
});
});
describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
@@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
elbowed: true,
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
pointFrom(45 - arrow.x, 99.9 - arrow.y),
]);
mutateElement(arrow, {
points: [
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
],
});
expect(arrow.points).toEqual([
[0, 0],
[0, 100],
@@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});
expect(arrow.points).toEqual([
[0, 0],
@@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0],
[35, 0],
[35, 90],
[35, 90], // Note that coordinates are rounded above!
[35, 165],
[103, 165],
]);
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,7 +1,11 @@
import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types";
import { getFontString, updateActiveTool } from "../utils";
import {
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping";
@@ -11,7 +15,6 @@ import type {
ExcalidrawIframeLikeElement,
IframeData,
} from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
import type { MarkRequired } from "../utility-types";
import { StoreAction } from "../store";
+4 -12
View File
@@ -452,20 +452,12 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(
bindingArrow,
[
{
index: 1,
point: bindingArrow.points[1],
},
],
elementsMap as NonDeletedSceneElementsMap,
undefined,
LinearElementEditor.movePoints(bindingArrow, [
{
changedElements,
index: 1,
point: bindingArrow.points[1],
},
);
]);
return bindingArrow;
};
+31 -7
View File
@@ -11,6 +11,7 @@ import {
pointScaleFromOrigin,
radiansToDegrees,
triangleIncludesPoint,
vectorFromPoint,
} from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
@@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => {
return HEADING_UP;
};
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => vectorToHeading(vectorFromPoint<P>(p, o));
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => headingIsHorizontal(headingForPoint<P>(p, o));
export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1];
export const headingIsHorizontal = (a: Heading) =>
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
// 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.
@@ -63,7 +79,7 @@ export const headingForPointFromElement = <
>(
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
p: Readonly<LocalPoint | GlobalPoint>,
p: Readonly<Point>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
@@ -117,14 +133,22 @@ export const headingForPointFromElement = <
element.angle,
);
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
if (
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(top, right);
} else if (
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
triangleIncludesPoint<Point>(
[right, bottom, midPoint] as Triangle<Point>,
p,
)
) {
return headingForDiamond(right, bottom);
} else if (
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
triangleIncludesPoint<Point>(
[bottom, left, midPoint] as Triangle<Point>,
p,
)
) {
return headingForDiamond(bottom, left);
}
@@ -153,17 +177,17 @@ export const headingForPointFromElement = <
SEARCH_CONE_MULTIPLIER,
) as Point;
return triangleIncludesPoint(
return triangleIncludesPoint<Point>(
[topLeft, topRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_UP
: triangleIncludesPoint(
: triangleIncludesPoint<Point>(
[topRight, bottomRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_RIGHT
: triangleIncludesPoint(
: triangleIncludesPoint<Point>(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p,
)
+3 -1
View File
@@ -124,7 +124,9 @@ export const normalizeSVG = (SVGString: string) => {
height = height || "50";
if (viewBox) {
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
const match = viewBox.match(
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
);
if (match) {
[, width, height] = match;
}
+186 -124
View File
@@ -7,9 +7,10 @@ import type {
ExcalidrawTextElementWithContainer,
ElementsMap,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
FixedPointBinding,
SceneElementsMap,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds";
@@ -24,6 +25,7 @@ import type {
InteractiveCanvasAppState,
AppClassProperties,
NullableGridSize,
Zoom,
} from "../types";
import { mutateElement } from "./mutateElement";
@@ -32,7 +34,7 @@ import {
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
import { invariant, toBrandedType, tupleToCoors } from "../utils";
import { invariant, tupleToCoors } from "../utils";
import {
isBindingElement,
isElbowArrow,
@@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene";
import type { Radians } from "../../math";
import {
@@ -56,6 +57,8 @@ import {
type GlobalPoint,
type LocalPoint,
pointDistance,
pointTranslate,
vectorFromPoint,
} from "../../math";
import {
getBezierCurveLength,
@@ -65,6 +68,7 @@ import {
mapIntervalToBezierT,
} from "../shapes";
import { getGridPoint } from "../snapping";
import { headingIsHorizontal, vectorToHeading } from "./heading";
const editorMidPointsCache: {
version: number | null;
@@ -144,13 +148,13 @@ export class LinearElementEditor {
* @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement)
*/
static getElement(
static getElement<T extends ExcalidrawLinearElement>(
id: InstanceType<typeof LinearElementEditor>["elementId"],
elementsMap: ElementsMap,
) {
): T | null {
const element = elementsMap.get(id);
if (element) {
return element as NonDeleted<ExcalidrawLinearElement>;
return element as NonDeleted<T>;
}
return null;
}
@@ -291,20 +295,16 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
LinearElementEditor.movePoints(
element,
[
{
index: selectedIndex,
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
],
elementsMap,
);
LinearElementEditor.movePoints(element, [
{
index: selectedIndex,
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
]);
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
@@ -339,7 +339,6 @@ export class LinearElementEditor {
isDragging: pointIndex === lastClickedPoint,
};
}),
elementsMap,
);
}
@@ -422,19 +421,15 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1
) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(
element,
[
{
index: selectedPoint,
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
],
elementsMap,
);
LinearElementEditor.movePoints(element, [
{
index: selectedPoint,
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
]);
}
const bindingElement = isBindingEnabled(appState)
@@ -448,6 +443,7 @@ export class LinearElementEditor {
),
elements,
elementsMap,
appState.zoom,
)
: null;
@@ -494,6 +490,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
!isElbowArrow(element) &&
!appState.editingLinearElement &&
element.points.length > 2 &&
!boundText
@@ -532,6 +529,7 @@ export class LinearElementEditor {
element,
element.points[index],
element.points[index + 1],
index,
appState.zoom,
)
) {
@@ -572,19 +570,23 @@ export class LinearElementEditor {
scenePointer.x,
scenePointer.y,
);
if (clickedPointIndex >= 0) {
if (!isElbowArrow(element) && clickedPointIndex >= 0) {
return null;
}
const points = LinearElementEditor.getPointsGlobalCoordinates(
element,
elementsMap,
);
if (points.length >= 3 && !appState.editingLinearElement) {
if (
points.length >= 3 &&
!appState.editingLinearElement &&
!isElbowArrow(element)
) {
return null;
}
const threshold =
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
(LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
@@ -603,10 +605,11 @@ export class LinearElementEditor {
let index = 0;
const midPoints: typeof editorMidPointsCache["points"] =
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
const distance = pointDistance(
pointFrom(midPoints[index]![0], midPoints[index]![1]),
midPoints[index]!,
pointFrom(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
@@ -619,16 +622,25 @@ export class LinearElementEditor {
return null;
};
static isSegmentTooShort(
static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: GlobalPoint | LocalPoint,
endPoint: GlobalPoint | LocalPoint,
zoom: AppState["zoom"],
startPoint: P,
endPoint: P,
index: number,
zoom: Zoom,
) {
let distance = pointDistance(
pointFrom(startPoint[0], startPoint[1]),
pointFrom(endPoint[0], endPoint[1]),
);
if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) {
return (
pointDistance(startPoint, endPoint) * zoom.value <
LinearElementEditor.POINT_HANDLE_SIZE / 2
);
}
return false;
}
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
}
@@ -747,12 +759,8 @@ export class LinearElementEditor {
segmentMidpoint,
elementsMap,
);
}
if (event.altKey && appState.editingLinearElement) {
if (
linearElementEditor.lastUncommittedPoint == null &&
!isElbowArrow(element)
) {
} else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, {
points: [
...element.points,
@@ -787,6 +795,7 @@ export class LinearElementEditor {
scenePointer,
elements,
elementsMap,
app.state.zoom,
),
};
@@ -907,11 +916,7 @@ export class LinearElementEditor {
if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(
element,
[points.length - 1],
elementsMap,
);
LinearElementEditor.deletePoints(element, [points.length - 1]);
}
return {
...appState.editingLinearElement,
@@ -949,22 +954,14 @@ export class LinearElementEditor {
}
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(
element,
[
{
index: element.points.length - 1,
point: newPoint,
},
],
elementsMap,
);
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: newPoint,
},
]);
} else {
LinearElementEditor.addPoints(
element,
[{ point: newPoint }],
elementsMap,
);
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
}
return {
...appState.editingLinearElement,
@@ -1193,16 +1190,12 @@ export class LinearElementEditor {
// potentially expanding the bounding box
if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(
element,
[
{
index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
},
],
elementsMap,
);
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
},
]);
}
return {
@@ -1217,7 +1210,6 @@ export class LinearElementEditor {
static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
let offsetX = 0;
let offsetY = 0;
@@ -1247,45 +1239,27 @@ export class LinearElementEditor {
return acc;
}, []);
LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
const offsetX = 0;
const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
},
) {
const { points } = element;
@@ -1328,7 +1302,6 @@ export class LinearElementEditor {
nextPoints,
offsetX,
offsetY,
elementsMap,
otherUpdates,
{
isDragging: targetPoints.reduce(
@@ -1336,7 +1309,6 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true,
false,
),
changedElements: options?.changedElements,
},
);
}
@@ -1443,52 +1415,49 @@ export class LinearElementEditor {
nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
if (isElbowArrow(element)) {
const bindings: {
const updates: {
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
points?: LocalPoint[];
} = {};
if (otherUpdates?.startBinding !== undefined) {
bindings.startBinding =
updates.startBinding =
otherUpdates.startBinding !== null &&
isFixedPointBinding(otherUpdates.startBinding)
? otherUpdates.startBinding
: null;
}
if (otherUpdates?.endBinding !== undefined) {
bindings.endBinding =
updates.endBinding =
otherUpdates.endBinding !== null &&
isFixedPointBinding(otherUpdates.endBinding)
? otherUpdates.endBinding
: null;
}
const mergedElementsMap = options?.changedElements
? toBrandedType<SceneElementsMap>(
new Map([...elementsMap, ...options.changedElements]),
)
: elementsMap;
mutateElbowArrow(
element,
mergedElementsMap,
nextPoints,
updates.points = Array.from(nextPoints);
updates.points[0] = pointTranslate(
updates.points[0],
vector(offsetX, offsetY),
bindings,
{
isDragging: options?.isDragging,
},
);
updates.points[updates.points.length - 1] = pointTranslate(
updates.points[updates.points.length - 1],
vector(offsetX, offsetY),
);
mutateElement(element, updates, true, {
isDragging: options?.isDragging,
});
} else {
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points);
@@ -1763,6 +1732,99 @@ export class LinearElementEditor {
return coords;
};
static moveFixedSegment(
linearElement: LinearElementEditor,
index: number,
x: number,
y: number,
elementsMap: ElementsMap,
): LinearElementEditor {
const element = LinearElementEditor.getElement(
linearElement.elementId,
elementsMap,
);
if (!element || !isElbowArrow(element)) {
return linearElement;
}
if (index && index > 0 && index < element.points.length) {
const isHorizontal = headingIsHorizontal(
vectorToHeading(
vectorFromPoint(element.points[index], element.points[index - 1]),
),
);
const fixedSegments = (element.fixedSegments ?? []).reduce(
(segments, s) => {
segments[s.index] = s;
return segments;
},
{} as Record<number, FixedSegment>,
);
fixedSegments[index] = {
index,
start: pointFrom<LocalPoint>(
!isHorizontal ? x - element.x : element.points[index - 1][0],
isHorizontal ? y - element.y : element.points[index - 1][1],
),
end: pointFrom<LocalPoint>(
!isHorizontal ? x - element.x : element.points[index][0],
isHorizontal ? y - element.y : element.points[index][1],
),
};
const nextFixedSegments = Object.values(fixedSegments).sort(
(a, b) => a.index - b.index,
);
const offset = nextFixedSegments
.map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
mutateElement(element, {
fixedSegments: nextFixedSegments,
});
const point = pointFrom<GlobalPoint>(
element.x +
(element.fixedSegments![offset].start[0] +
element.fixedSegments![offset].end[0]) /
2,
element.y +
(element.fixedSegments![offset].start[1] +
element.fixedSegments![offset].end[1]) /
2,
);
return {
...linearElement,
segmentMidPointHoveredCoords: point,
pointerDownState: {
...linearElement.pointerDownState,
segmentMidpoint: {
added: false,
index: element.fixedSegments![offset].index,
value: point,
},
},
};
}
return linearElement;
}
static deleteFixedSegment(
element: ExcalidrawElbowArrowElement,
index: number,
): void {
mutateElement(element, {
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),
});
mutateElement(element, {}, true);
}
}
const normalizeSelectedPoints = (
+42 -4
View File
@@ -1,10 +1,13 @@
import type { ExcalidrawElement } from "./types";
import type { ExcalidrawElement, SceneElementsMap } from "./types";
import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import { getUpdatedTimestamp } from "../utils";
import { getUpdatedTimestamp, toBrandedType } from "../utils";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { isElbowArrow } from "./typeChecks";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Radians } from "../../math";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
@@ -19,14 +22,49 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
informMutation = true,
options?: {
// Currently only for elbow arrows.
// If true, the elbow arrow tries to bind to the nearest element. If false
// it tries to keep the same bound element, if any.
isDragging?: boolean;
},
): TElement => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fileId } = updates as any;
const { points, fixedSegments, fileId } = updates as any;
if (typeof points !== "undefined") {
if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined") // segment fixing
) {
const elementsMap = toBrandedType<SceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
);
updates = {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
{
...element,
x: updates.x || element.x,
y: updates.y || element.y,
},
elementsMap,
{
fixedSegments,
points,
},
{
isDragging: options?.isDragging,
},
),
};
} else if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
}
+28 -5
View File
@@ -18,6 +18,8 @@ import type {
ExcalidrawIframeElement,
ElementsMap,
ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types";
import {
arrayToMap,
@@ -450,15 +452,34 @@ export const newLinearElement = (
};
};
export const newArrowElement = (
export const newArrowElement = <T extends boolean>(
opts: {
type: ExcalidrawArrowElement["type"];
startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"];
elbowed?: boolean;
elbowed?: T;
fixedSegments?: FixedSegment[] | null;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => {
): T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement> => {
if (opts.elbowed) {
return {
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: true,
fixedSegments: opts.fixedSegments || [],
startIsSpecial: false,
endIsSpecial: false,
} as NonDeleted<ExcalidrawElbowArrowElement>;
}
return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [],
@@ -467,8 +488,10 @@ export const newArrowElement = (
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: opts.elbowed || false,
};
elbowed: false,
} as T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement>;
};
export const newImageElement = (
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -303,7 +303,10 @@ export type Arrowhead =
| "triangle"
| "triangle_outline"
| "diamond"
| "diamond_outline";
| "diamond_outline"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
@@ -316,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null;
}>;
export type FixedSegment = {
start: LocalPoint;
end: LocalPoint;
index: number;
};
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{
type: "arrow";
@@ -328,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge<
elbowed: true;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
fixedSegments: FixedSegment[] | null;
/**
* Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing
* the data from the points array. It allows creating the expected arrow
* path when the arrow with fixed segments is bound on a horizontal side and
* moved to a vertical and vica versa.
*/
startIsSpecial: boolean | null;
/**
* Marks that the 3rd point backwards from the end should be used as the 2nd
* point of the arrow in order to temporarily hide the last segment of the
* arrow without losing the data from the points array. It allows creating
* the expected arrow path when the arrow with fixed segments is bound on a
* horizontal side and moved to a vertical and vica versa.
*/
endIsSpecial: boolean | null;
}
>;
-1
View File
@@ -190,7 +190,6 @@ export const syncInvalidIndices = (
): OrderedExcalidrawElement[] => {
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false);
}
+4
View File
@@ -105,6 +105,10 @@ export const selectGroupsForSelectedElements = (function () {
const groupElementsIndex: Record<GroupId, string[]> = {};
const selectedElementIdsInGroups = elements.reduce(
(acc: Record<string, true>, element) => {
if (element.isDeleted) {
return acc;
}
const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
if (groupId) {
@@ -1,7 +1,6 @@
import { atom, useAtom } from "jotai";
import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "../colors";
import { jotaiScope } from "../jotai";
import { atom, useAtom } from "../editor-jotai";
import { exportToSvg } from "../../utils/export";
import type { LibraryItem } from "../types";
@@ -64,7 +63,7 @@ export const useLibraryItemSvg = (
};
export const useLibraryCache = () => {
const [svgCache] = useAtom(libraryItemSvgsCache, jotaiScope);
const [svgCache] = useAtom(libraryItemSvgsCache);
const clearLibraryCache = () => svgCache.clear();
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { atom, useAtom } from "jotai";
import { atom, useAtom } from "../editor-jotai";
import throttle from "lodash.throttle";
const scrollPositionAtom = atom<number>(0);
+3 -4
View File
@@ -1,7 +1,6 @@
import fallbackLangData from "./locales/en.json";
import percentages from "./locales/percentages.json";
import { jotaiScope, jotaiStore } from "./jotai";
import { atom, useAtomValue } from "jotai";
import { useAtomValue, editorJotaiStore, atom } from "./editor-jotai";
import type { NestedKeyOf } from "./utility-types";
const COMPLETION_THRESHOLD = 85;
@@ -103,7 +102,7 @@ export const setLanguage = async (lang: Language) => {
}
}
jotaiStore.set(editorLangCodeAtom, lang.code);
editorJotaiStore.set(editorLangCodeAtom, lang.code);
};
export const getLanguage = () => currentLang;
@@ -165,6 +164,6 @@ const editorLangCodeAtom = atom(defaultLang.code);
// - component is rendered internally by <Excalidraw>, but the component
// is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
export const useI18n = () => {
const langCode = useAtomValue(editorLangCodeAtom, jotaiScope);
const langCode = useAtomValue(editorLangCodeAtom);
return { t, langCode };
};
+3 -4
View File
@@ -11,8 +11,7 @@ import "./fonts/fonts.css";
import type { AppProps, ExcalidrawProps } from "./types";
import { defaultLang } from "./i18n";
import { DEFAULT_UI_OPTIONS } from "./constants";
import { Provider } from "jotai";
import { jotaiScope, jotaiStore } from "./jotai";
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
import Footer from "./components/footer/FooterCenter";
import MainMenu from "./components/main-menu/MainMenu";
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
@@ -108,7 +107,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
}, []);
return (
<Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
<EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}>
<App
onChange={onChange}
@@ -145,7 +144,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
{children}
</App>
</InitializeApp>
</Provider>
</EditorJotaiProvider>
);
};
-28
View File
@@ -1,28 +0,0 @@
import type { PrimitiveAtom } from "jotai";
import { unstable_createStore, useAtom } from "jotai";
import { useLayoutEffect } from "react";
export const jotaiScope = Symbol();
export const jotaiStore = unstable_createStore();
export const useAtomWithInitialValue = <
T extends unknown,
A extends PrimitiveAtom<T>,
>(
atom: A,
initialValue: T | (() => T),
) => {
const [value, setValue] = useAtom(atom);
useLayoutEffect(() => {
if (typeof initialValue === "function") {
// @ts-ignore
setValue(initialValue());
} else {
setValue(initialValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [value, setValue] as const;
};
+8 -1
View File
@@ -46,6 +46,10 @@
"arrowhead_triangle_outline": "Triangle (outline)",
"arrowhead_diamond": "Diamond",
"arrowhead_diamond_outline": "Diamond (outline)",
"arrowhead_crowfoot_many": "Crow's foot (many)",
"arrowhead_crowfoot_one": "Crow's foot (one)",
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
"more_options": "More options",
"arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow",
@@ -157,8 +161,11 @@
"zoomToFit": "Zoom to fit all elements",
"installPWA": "Install Excalidraw locally (PWA)",
"autoResize": "Enable text auto-resizing",
"imageCropping": "Image cropping",
"unCroppedDimension": "Uncropped dimension",
"copyElementLink": "Copy link to object",
"linkToElement": "Link to object"
"linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame"
},
"elementLink": {
"title": "Link to object",
+4 -2
View File
@@ -70,9 +70,10 @@
"fractional-indexing": "3.2.0",
"fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1",
"jotai": "1.13.1",
"jotai": "2.11.0",
"jotai-scope": "0.7.2",
"lodash.throttle": "4.1.1",
"nanoid": "3.3.8",
"nanoid": "3.3.3",
"open-color": "1.9.1",
"pako": "1.0.11",
"perfect-freehand": "1.2.0",
@@ -116,6 +117,7 @@
"fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6",
"import-meta-loader": "1.1.0",
"jest-diff": "29.7.0",
"mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1",
"sass-loader": "13.0.2",
@@ -29,7 +29,7 @@ import {
getOmitSidesForDevice,
shouldShowBoundingBox,
} from "../element/transformHandles";
import { arrayToMap, throttleRAF } from "../utils";
import { arrayToMap, invariant, throttleRAF } from "../utils";
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE,
@@ -43,7 +43,11 @@ import type {
SuggestedBinding,
SuggestedPointBinding,
} from "../element/binding";
import { maxBindingGap } from "../element/binding";
import {
BINDING_HIGHLIGHT_OFFSET,
BINDING_HIGHLIGHT_THICKNESS,
maxBindingGap,
} from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
bootstrapCanvas,
@@ -74,9 +78,32 @@ import type {
InteractiveSceneRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import type { GlobalPoint, LocalPoint, Radians } from "../../math";
import {
pointFrom,
type GlobalPoint,
type LocalPoint,
type Radians,
} from "../../math";
import { getCornerRadius } from "../shapes";
const renderElbowArrowMidPointHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
) => {
invariant(appState.selectedLinearElement, "selectedLinearElement is null");
const { segmentMidPointHoveredCoords } = appState.selectedLinearElement;
invariant(segmentMidPointHoveredCoords, "midPointCoords is null");
context.save();
context.translate(appState.scrollX, appState.scrollY);
highlightPoint(segmentMidPointHoveredCoords, context, appState);
context.restore();
};
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
@@ -217,17 +244,18 @@ const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
const thickness = 10;
// So that we don't overlap the element itself
const strokeOffset = 4;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = thickness - strokeOffset;
const padding = strokeOffset / 2 + thickness / 2;
// When zooming out, make line width greater for visibility
const zoomValue = zoom.value < 1 ? zoom.value : 1;
context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
// To ensure the binding highlight doesn't overlap the element itself
const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
const radius = getCornerRadius(
Math.min(element.width, element.height),
@@ -285,6 +313,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
context: CanvasRenderingContext2D,
suggestedBinding: SuggestedPointBinding,
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const [element, startOrEnd, bindableElement] = suggestedBinding;
@@ -292,6 +321,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
context.strokeStyle = "rgba(0,0,0,0)";
@@ -390,7 +420,7 @@ const renderBindingHighlight = (
context.save();
context.translate(appState.scrollX, appState.scrollY);
renderHighlight(context, suggestedBinding as any, elementsMap);
renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom);
context.restore();
};
@@ -483,7 +513,7 @@ const renderLinearPointHandles = (
context.save();
context.translate(appState.scrollX, appState.scrollY);
context.lineWidth = 1 / appState.zoom.value;
const points = LinearElementEditor.getPointsGlobalCoordinates(
const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates(
element,
elementsMap,
);
@@ -503,55 +533,57 @@ const renderLinearPointHandles = (
renderSingleLinearPoint(context, appState, point, radius, isSelected);
});
//Rendering segment mid points
const midPoints = LinearElementEditor.getEditorMidPoints(
element,
elementsMap,
appState,
).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
midPoints.forEach((segmentMidPoint) => {
if (
appState?.selectedLinearElement?.segmentMidPointHoveredCoords &&
LinearElementEditor.arePointsEqual(
segmentMidPoint,
appState.selectedLinearElement.segmentMidPointHoveredCoords,
)
) {
// The order of renderingSingleLinearPoint and highLight points is different
// inside vs outside editor as hover states are different,
// in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
// editor original point is visible and hover state is just an outer circle.
if (appState.editingLinearElement) {
// Rendering segment mid points
if (isElbowArrow(element)) {
const fixedSegments =
element.fixedSegments?.map((segment) => segment.index) || [];
points.slice(0, -1).forEach((p, idx) => {
if (
!LinearElementEditor.isSegmentTooShort(
element,
points[idx + 1],
points[idx],
idx,
appState.zoom,
)
) {
renderSingleLinearPoint(
context,
appState,
segmentMidPoint,
radius,
false,
);
highlightPoint(segmentMidPoint, context, appState);
} else {
highlightPoint(segmentMidPoint, context, appState);
renderSingleLinearPoint(
context,
appState,
segmentMidPoint,
radius,
pointFrom<GlobalPoint>(
(p[0] + points[idx + 1][0]) / 2,
(p[1] + points[idx + 1][1]) / 2,
),
POINT_HANDLE_SIZE / 2,
false,
!fixedSegments.includes(idx + 1),
);
}
} else if (appState.editingLinearElement || points.length === 2) {
renderSingleLinearPoint(
context,
appState,
segmentMidPoint,
POINT_HANDLE_SIZE / 2,
false,
true,
);
}
});
});
} else {
const midPoints = LinearElementEditor.getEditorMidPoints(
element,
elementsMap,
appState,
).filter(
(midPoint, idx, midPoints): midPoint is GlobalPoint =>
midPoint !== null &&
!(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)),
);
midPoints.forEach((segmentMidPoint) => {
if (appState.editingLinearElement || points.length === 2) {
renderSingleLinearPoint(
context,
appState,
segmentMidPoint,
POINT_HANDLE_SIZE / 2,
false,
true,
);
}
});
}
context.restore();
};
@@ -857,6 +889,12 @@ const _renderInteractiveScene = ({
}
if (
isElbowArrow(selectedElements[0]) &&
appState.selectedLinearElement &&
appState.selectedLinearElement.segmentMidPointHoveredCoords
) {
renderElbowArrowMidPointHighlight(context, appState);
} else if (
appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex >= 0 &&
!(
@@ -868,6 +906,7 @@ const _renderInteractiveScene = ({
) {
renderLinearElementPointHighlight(context, appState, elementsMap);
}
// Paint selected elements
if (!appState.multiElement && !appState.editingLinearElement) {
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
@@ -449,7 +449,7 @@ const renderElementToSvg = (
symbol.appendChild(image);
root.prepend(symbol);
(root.querySelector("defs") || root).prepend(symbol);
}
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
+49 -22
View File
@@ -23,13 +23,9 @@ import {
} from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons";
import type { EmbedsValidationStatus } from "../types";
import {
pointFrom,
pointDistance,
type GlobalPoint,
type LocalPoint,
} from "../../math";
import { pointFrom, pointDistance, type LocalPoint } from "../../math";
import { getCornerRadius, isPathALoop } from "../shapes";
import { headingForPointIsHorizontal } from "../element/heading";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -177,6 +173,19 @@ const getArrowheadShapes = (
return [];
}
const generateCrowfootOne = (
arrowheadPoints: number[] | null,
options: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
switch (arrowhead) {
case "dot":
case "circle":
@@ -255,8 +264,12 @@ const getArrowheadShapes = (
),
];
}
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar":
case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
@@ -272,6 +285,12 @@ const getArrowheadShapes = (
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
];
}
}
@@ -504,45 +523,53 @@ export const _generateElementShape = (
}
};
const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
const generateElbowArrowShape = (
points: readonly LocalPoint[],
radius: number,
) => {
const subpoints = [] as [number, number][];
for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1];
const next = points[i + 1];
const point = points[i];
const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
const nextIsHorizontal = headingForPointIsHorizontal(next, point);
const corner = Math.min(
radius,
pointDistance(points[i], next) / 2,
pointDistance(points[i], prev) / 2,
);
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else if (prev[0] === points[i][0] && prev[1] < points[i][1]) {
if (prevIsHorizontal) {
if (prev[0] < point[0]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
}
} else if (prev[1] < point[1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else if (prev[0] > points[i][0] && prev[1] === points[i][1]) {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
} else {
subpoints.push([points[i][0], points[i][1] + corner]);
}
subpoints.push(points[i] as [number, number]);
if (next[0] < points[i][0] && next[1] === points[i][1]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else if (next[0] === points[i][0] && next[1] < points[i][1]) {
if (nextIsHorizontal) {
if (next[0] < point[0]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
}
} else if (next[1] < point[1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else if (next[0] > points[i][0] && next[1] === points[i][1]) {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
} else {
// DOWN
subpoints.push([points[i][0], points[i][1] + corner]);
}
}
+163 -53
View File
@@ -18,6 +18,8 @@ import {
SVG_NS,
THEME,
THEME_FILTER,
MIME_TYPES,
EXPORT_DATA_TYPES,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json";
@@ -39,8 +41,7 @@ import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) {
@@ -254,6 +255,13 @@ export const exportToCanvas = async (
return canvas;
};
const createHTMLComment = (text: string) => {
// surrounding with spaces to maintain prettified consistency with previous
// iterations
// <!-- comment -->
return document.createComment(` ${text} `);
};
export const exportToSvg = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: {
@@ -302,31 +310,20 @@ export const exportToSvg = async (
exportPadding = 0;
}
let metadata = "";
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) {
try {
metadata = (await import("../data/image")).encodeSvgMetadata({
// when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
text: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (error: any) {
console.error(error);
}
}
const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding,
);
// initialize SVG root
const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
// ---------------------------------------------------------------------------
// initialize SVG root element
// ---------------------------------------------------------------------------
const svgRoot = document.createElementNS(SVG_NS, "svg");
svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
@@ -336,53 +333,105 @@ export const exportToSvg = async (
svgRoot.setAttribute("filter", THEME_FILTER);
}
const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
const metadataElement = svgRoot.ownerDocument.createElementNS(
SVG_NS,
"metadata",
);
svgRoot.appendChild(createHTMLComment("svg-source:excalidraw"));
svgRoot.appendChild(metadataElement);
svgRoot.appendChild(defsElement);
// ---------------------------------------------------------------------------
// scene embed
// ---------------------------------------------------------------------------
// we need to serialize the "original" elements before we put them through
// the tempScene hack which duplicates and regenerates ids
if (exportEmbedScene) {
try {
encodeSvgBase64Payload({
metadataElement,
// when embedding scene, we want to embed the origionally supplied
// elements which don't contain the temp frame labels.
// But it also requires that the exportToSvg is being supplied with
// only the elements that we're exporting, and no extra.
payload: serializeAsJSON(elements, appState, files || {}, "local"),
});
} catch (error: any) {
console.error(error);
}
}
// ---------------------------------------------------------------------------
// frame clip paths
// ---------------------------------------------------------------------------
const frameElements = getFrameLikeElements(elements);
let exportingFrameClipPath = "";
const elementsMap = arrayToMap(elements);
for (const frame of frameElements) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
const cx = (x2 - x1) / 2 - (frame.x - x1);
const cy = (y2 - y1) / 2 - (frame.y - y1);
if (frameElements.length) {
const elementsMap = arrayToMap(elements);
exportingFrameClipPath += `<clipPath id=${frame.id}>
<rect transform="translate(${frame.x + offsetX} ${
frame.y + offsetY
}) rotate(${frame.angle} ${cx} ${cy})"
width="${frame.width}"
height="${frame.height}"
${
exportingFrame
? ""
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
}
>
</rect>
</clipPath>`;
for (const frame of frameElements) {
const clipPath = svgRoot.ownerDocument.createElementNS(
SVG_NS,
"clipPath",
);
clipPath.setAttribute("id", frame.id);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
const cx = (x2 - x1) / 2 - (frame.x - x1);
const cy = (y2 - y1) / 2 - (frame.y - y1);
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute(
"transform",
`translate(${frame.x + offsetX} ${frame.y + offsetY}) rotate(${
frame.angle
} ${cx} ${cy})`,
);
rect.setAttribute("width", `${frame.width}`);
rect.setAttribute("height", `${frame.height}`);
if (!exportingFrame) {
rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
}
clipPath.appendChild(rect);
defsElement.appendChild(clipPath);
}
}
// ---------------------------------------------------------------------------
// inline font faces
// ---------------------------------------------------------------------------
const fontFaces = !opts?.skipInliningFonts
? await Fonts.generateFontFaceDeclarations(elements)
: [];
const delimiter = "\n "; // 6 spaces
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
<defs>
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
</style>
${exportingFrameClipPath}
</defs>
`;
const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style");
style.classList.add("style-fonts");
style.appendChild(
document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`),
);
defsElement.appendChild(style);
// ---------------------------------------------------------------------------
// background
// ---------------------------------------------------------------------------
// render background rect
if (appState.exportBackground && viewBackgroundColor) {
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", "0");
rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`);
@@ -391,6 +440,10 @@ export const exportToSvg = async (
svgRoot.appendChild(rect);
}
// ---------------------------------------------------------------------------
// render elements
// ---------------------------------------------------------------------------
const rsvg = rough.svg(svgRoot);
const renderEmbeddables = opts?.renderEmbeddables ?? false;
@@ -420,9 +473,66 @@ export const exportToSvg = async (
},
);
// ---------------------------------------------------------------------------
return svgRoot;
};
export const encodeSvgBase64Payload = ({
payload,
metadataElement,
}: {
payload: string;
metadataElement: SVGMetadataElement;
}) => {
const base64 = stringToBase64(
JSON.stringify(encode({ text: payload })),
true /* is already byte string */,
);
metadataElement.appendChild(
createHTMLComment(`payload-type:${MIME_TYPES.excalidraw}`),
);
metadataElement.appendChild(createHTMLComment("payload-version:2"));
metadataElement.appendChild(createHTMLComment("payload-start"));
metadataElement.appendChild(document.createTextNode(base64));
metadataElement.appendChild(createHTMLComment("payload-end"));
};
export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) {
throw new Error("INVALID");
}
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
const version = versionMatch?.[1] || "1";
const isByteString = version !== "1";
try {
const json = base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return json;
}
throw new Error("FAILED");
}
return decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};
// calculate smallest area to fit the contents in
const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -97,6 +97,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "element",
},
},
"separator",
{
"label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame",
@@ -115,6 +116,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "history",
},
},
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
@@ -1006,14 +1016,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"roundness": {
"type": 3,
},
"seed": 1278240551,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 2,
"versionNonce": 453191,
"versionNonce": 1278240551,
"width": 100,
"x": 0,
"y": 0,
@@ -1042,14 +1052,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"roundness": {
"type": 3,
},
"seed": 449462985,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 2,
"versionNonce": 401146281,
"versionNonce": 449462985,
"width": 100,
"x": 0,
"y": 0,
@@ -4731,6 +4741,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "element",
},
},
"separator",
{
"label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame",
@@ -4749,6 +4760,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "history",
},
},
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
@@ -5942,6 +5962,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "element",
},
},
"separator",
{
"label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame",
@@ -5960,6 +5981,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "history",
},
},
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
@@ -7876,6 +7906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element",
},
},
"separator",
{
"label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame",
@@ -7894,6 +7925,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "history",
},
},
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
@@ -8854,6 +8894,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element",
},
},
"separator",
{
"label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame",
@@ -8872,6 +8913,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "history",
},
},
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
@@ -9792,14 +9842,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"roundness": {
"type": 3,
},
"seed": 1278240551,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 2,
"versionNonce": 453191,
"versionNonce": 1278240551,
"width": 200,
"x": 0,
"y": 0,
@@ -9826,14 +9876,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"roundness": {
"type": 3,
},
"seed": 449462985,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 2,
"versionNonce": 401146281,
"versionNonce": 449462985,
"width": 200,
"x": 0,
"y": 0,
File diff suppressed because one or more lines are too long
@@ -10983,7 +10983,9 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00161",
"gap": "3.53708",
},
"endIsSpecial": null,
"fillStyle": "solid",
"fixedSegments": null,
"frameId": null,
"groupIds": [],
"height": "448.10100",
@@ -11000,9 +11002,13 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
0,
],
[
"451.90000",
"225.95000",
0,
],
[
"225.95000",
"448.10100",
],
[
"451.90000",
"448.10100",
@@ -11022,6 +11028,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00159",
"gap": 5,
},
"startIsSpecial": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -11147,7 +11154,9 @@ History {
"focus": "-0.00161",
"gap": "3.53708",
},
"endIsSpecial": false,
"fillStyle": "solid",
"fixedSegments": [],
"frameId": null,
"groupIds": [],
"height": "236.10000",
@@ -11185,6 +11194,7 @@ History {
"focus": "-0.00159",
"gap": 5,
},
"startIsSpecial": false,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@@ -4307,14 +4307,20 @@ History {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {
"editingGroupId": null,
"selectedElementIds": {
"id1": true,
},
"selectedGroupIds": {
"id4": false,
},
},
"inserted": {
"editingGroupId": "id4",
"selectedElementIds": {
"id0": true,
},
"selectedGroupIds": {},
},
},
},
@@ -4337,14 +4343,16 @@ History {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {
"editingGroupId": null,
"selectedElementIds": {},
"selectedGroupIds": {},
},
"inserted": {
"editingGroupId": "id4",
"selectedElementIds": {
"id1": true,
},
"selectedGroupIds": {
"id4": false,
},
},
},
},

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