Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c34b3f48a | |||
| 683b80ad2b | |||
| d636abff79 | |||
| 47d8fa542c | |||
| 34cf71b0f4 | |||
| ceb255e8ee | |||
| ae5b9a4ffd | |||
| 3d4ff59f40 | |||
| 7b00089314 | |||
| af6b81df40 | |||
| 02cc8440c4 | |||
| 6363492cee | |||
| 900b317bf3 | |||
| 68179356e6 | |||
| 3ed15e95da | |||
| 798e1fd858 | |||
| f66c93633c | |||
| a30e46b756 | |||
| cee00767df | |||
| 71ba0a3f26 | |||
| 9bcd0b69dc | |||
| 864c0b3ea8 | |||
| a9a6f8eafb | |||
| afed893419 | |||
| 3c96943db3 | |||
| 9006caff39 | |||
| ce7a847668 | |||
| b1037b342d |
@@ -34,7 +34,7 @@ Open the `Menu` in the below playground and you will see the `custom footer` ren
|
|||||||
```jsx live noInline
|
```jsx live noInline
|
||||||
const MobileFooter = ({}) => {
|
const MobileFooter = ({}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
if (device.isMobile) {
|
if (device.editor.isMobile) {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ Open the `main menu` in the below example to view the footer.
|
|||||||
```jsx live noInline
|
```jsx live noInline
|
||||||
const MobileFooter = ({}) => {
|
const MobileFooter = ({}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
if (device.isMobile) {
|
if (device.editor.isMobile) {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
@@ -335,7 +335,6 @@ The `device` has the following `attributes`
|
|||||||
|
|
||||||
| Name | Type | Description |
|
| Name | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `isSmScreen` | `boolean` | Set to `true` when the device small screen is small (Width < `640px` ) |
|
|
||||||
| `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
|
| `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
|
||||||
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
|
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
|
||||||
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
|
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
|
||||||
|
|||||||
@@ -34,19 +34,44 @@ function App() {
|
|||||||
|
|
||||||
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
|
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
|
||||||
|
|
||||||
The following workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
|
Here are two ways on how you can render **Excalidraw** on **Next.js**.
|
||||||
|
|
||||||
|
1. Importing Excalidraw once **client** is rendered.
|
||||||
|
|
||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [Excalidraw, setExcalidraw] = useState(null);
|
const [Excalidraw, setExcalidraw] = useState(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import("@excalidraw/excalidraw").then((comp) => setExcalidraw(comp.Excalidraw));
|
import("@excalidraw/excalidraw").then((comp) =>
|
||||||
|
setExcalidraw(comp.Excalidraw),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
return <>{Excalidraw && <Excalidraw />}</>;
|
return <>{Excalidraw && <Excalidraw />}</>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-5xb3d)
|
||||||
|
|
||||||
|
2. Using **Next.js Dynamic** import.
|
||||||
|
|
||||||
|
Since Excalidraw doesn't server side rendering so you can also use `dynamic import` to render by setting `ssr` to `false`. However one drawback is the `Refs` don't work with dynamic import in Next.js. We are working on overcoming this and have a better API.
|
||||||
|
|
||||||
|
```jsx showLineNumbers
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
const Excalidraw = dynamic(
|
||||||
|
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export default function App() {
|
||||||
|
return <Excalidraw />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-dynamic-k8yjq2).
|
||||||
|
|
||||||
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
|
The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm)
|
||||||
|
|
||||||
## Browser
|
## Browser
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ Frames should be ordered where frame children come first, followed by the frame
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
If not oredered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.
|
If not ordered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={excalidrawRefCallback}
|
excalidrawAPI={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ describe("Test MobileMenu", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
h.app.refreshViewportBreakpoints();
|
||||||
|
// @ts-ignore
|
||||||
|
h.app.refreshEditorBreakpoints();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -28,11 +30,15 @@ describe("Test MobileMenu", () => {
|
|||||||
it("should set device correctly", () => {
|
it("should set device correctly", () => {
|
||||||
expect(h.app.device).toMatchInlineSnapshot(`
|
expect(h.app.device).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"canDeviceFitSidebar": false,
|
"editor": {
|
||||||
"isLandscape": true,
|
"canFitSidebar": false,
|
||||||
"isMobile": true,
|
"isMobile": true,
|
||||||
"isSmScreen": false,
|
},
|
||||||
"isTouchScreen": false,
|
"isTouchScreen": false,
|
||||||
|
"viewport": {
|
||||||
|
"isLandscape": false,
|
||||||
|
"isMobile": true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../../excalidraw-app/collab/reconciliation";
|
} from "../../excalidraw-app/collab/reconciliation";
|
||||||
import { randomInteger } from "../../src/random";
|
import { randomInteger } from "../../src/random";
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
|
import { cloneJSON } from "../../src/utils";
|
||||||
|
|
||||||
type Id = string;
|
type Id = string;
|
||||||
type ElementLike = {
|
type ElementLike = {
|
||||||
@@ -93,8 +94,6 @@ const cleanElements = (elements: ReconciledElements) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data));
|
|
||||||
|
|
||||||
const test = <U extends `${string}:${"L" | "R"}`>(
|
const test = <U extends `${string}:${"L" | "R"}`>(
|
||||||
local: (Id | ElementLike)[],
|
local: (Id | ElementLike)[],
|
||||||
remote: (Id | ElementLike)[],
|
remote: (Id | ElementLike)[],
|
||||||
@@ -115,15 +114,15 @@ const test = <U extends `${string}:${"L" | "R"}`>(
|
|||||||
"remote reconciliation",
|
"remote reconciliation",
|
||||||
);
|
);
|
||||||
|
|
||||||
const __local = cleanElements(cloneDeep(_remote));
|
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
|
||||||
const __remote = addParents(cleanElements(cloneDeep(remoteReconciled)));
|
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
|
||||||
if (bidirectional) {
|
if (bidirectional) {
|
||||||
try {
|
try {
|
||||||
expect(
|
expect(
|
||||||
cleanElements(
|
cleanElements(
|
||||||
reconcileElements(
|
reconcileElements(
|
||||||
cloneDeep(__local),
|
cloneJSON(__local),
|
||||||
cloneDeep(__remote),
|
cloneJSON(__remote),
|
||||||
{} as AppState,
|
{} as AppState,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+2
-2
@@ -20,9 +20,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.2",
|
||||||
"@excalidraw/mermaid-to-excalidraw": "0.1.2",
|
|
||||||
"@excalidraw/laser-pointer": "1.2.0",
|
"@excalidraw/laser-pointer": "1.2.0",
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/mermaid-to-excalidraw": "0.1.2",
|
||||||
|
"@excalidraw/random-username": "1.1.0",
|
||||||
"@radix-ui/react-popover": "1.0.3",
|
"@radix-ui/react-popover": "1.0.3",
|
||||||
"@radix-ui/react-tabs": "1.0.2",
|
"@radix-ui/react-tabs": "1.0.2",
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
|
|||||||
@@ -438,5 +438,6 @@ export const actionToggleHandTool = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.key === KEYS.H,
|
keyTest: (event) =>
|
||||||
|
!event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
readSystemClipboard,
|
readSystemClipboard,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { exportCanvas } from "../data/index";
|
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||||
import { getNonDeletedElements, isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
|
|
||||||
@@ -122,20 +122,23 @@ export const actionCopyAsSvg = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
includeBoundTextElement: true,
|
elements,
|
||||||
includeElementsInFrames: true,
|
appState,
|
||||||
});
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
"clipboard-svg",
|
"clipboard-svg",
|
||||||
selectedElements.length
|
exportedElements,
|
||||||
? selectedElements
|
|
||||||
: getNonDeletedElements(elements),
|
|
||||||
appState,
|
appState,
|
||||||
app.files,
|
app.files,
|
||||||
appState,
|
{
|
||||||
|
...appState,
|
||||||
|
exportingFrame,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -171,16 +174,17 @@ export const actionCopyAsPng = register({
|
|||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
includeElementsInFrames: true,
|
includeElementsInFrames: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
true,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||||
"clipboard",
|
...appState,
|
||||||
selectedElements.length
|
exportingFrame,
|
||||||
? selectedElements
|
});
|
||||||
: getNonDeletedElements(elements),
|
|
||||||
appState,
|
|
||||||
app.files,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { normalizeElementOrder } from "../element/sortElements";
|
|||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
import {
|
import {
|
||||||
bindElementsToFramesAfterDuplication,
|
bindElementsToFramesAfterDuplication,
|
||||||
getFrameElements,
|
getFrameChildren,
|
||||||
} from "../frame";
|
} from "../frame";
|
||||||
import {
|
import {
|
||||||
excludeElementsInFramesFromSelection,
|
excludeElementsInFramesFromSelection,
|
||||||
@@ -155,7 +155,7 @@ const duplicateElements = (
|
|||||||
groupId,
|
groupId,
|
||||||
).flatMap((element) =>
|
).flatMap((element) =>
|
||||||
isFrameElement(element)
|
isFrameElement(element)
|
||||||
? [...getFrameElements(elements, element.id), element]
|
? [...getFrameChildren(elements, element.id), element]
|
||||||
: [element],
|
: [element],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ const duplicateElements = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isElementAFrame) {
|
if (isElementAFrame) {
|
||||||
const elementsInFrame = getFrameElements(sortedElements, element.id);
|
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||||
|
|
||||||
elementsWithClones.push(
|
elementsWithClones.push(
|
||||||
...markAsProcessed([
|
...markAsProcessed([
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
icon={saveAs}
|
icon={saveAs}
|
||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useDevice().isMobile}
|
showAriaLabel={useDevice().editor.isMobile}
|
||||||
hidden={!nativeFileSystemSupported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { removeAllElementsFromFrame } from "../frame";
|
import { removeAllElementsFromFrame } from "../frame";
|
||||||
import { getFrameElements } from "../frame";
|
import { getFrameChildren } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
@@ -21,7 +21,7 @@ export const actionSelectAllElementsInFrame = register({
|
|||||||
const selectedFrame = app.scene.getSelectedElements(appState)[0];
|
const selectedFrame = app.scene.getSelectedElements(appState)[0];
|
||||||
|
|
||||||
if (selectedFrame && selectedFrame.type === "frame") {
|
if (selectedFrame && selectedFrame.type === "frame") {
|
||||||
const elementsInFrame = getFrameElements(
|
const elementsInFrame = getFrameChildren(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
selectedFrame.id,
|
selectedFrame.id,
|
||||||
).filter((element) => !(element.type === "text" && element.containerId));
|
).filter((element) => !(element.type === "text" && element.containerId));
|
||||||
|
|||||||
+15
-13
@@ -17,15 +17,12 @@ import {
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import {
|
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawFrameElement,
|
|
||||||
ExcalidrawTextElement,
|
|
||||||
} from "../element/types";
|
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
getElementsInResizingFrame,
|
getElementsInResizingFrame,
|
||||||
|
getFrameElements,
|
||||||
groupByFrames,
|
groupByFrames,
|
||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
@@ -190,13 +187,6 @@ export const actionUngroup = register({
|
|||||||
|
|
||||||
let nextElements = [...elements];
|
let nextElements = [...elements];
|
||||||
|
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
|
||||||
const frames = selectedElements
|
|
||||||
.filter((element) => element.frameId)
|
|
||||||
.map((element) =>
|
|
||||||
app.scene.getElement(element.frameId!),
|
|
||||||
) as ExcalidrawFrameElement[];
|
|
||||||
|
|
||||||
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
||||||
nextElements = nextElements.map((element) => {
|
nextElements = nextElements.map((element) => {
|
||||||
if (isBoundToContainer(element)) {
|
if (isBoundToContainer(element)) {
|
||||||
@@ -221,7 +211,19 @@ export const actionUngroup = register({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
frames.forEach((frame) => {
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
|
const selectedElementFrameIds = new Set(
|
||||||
|
selectedElements
|
||||||
|
.filter((element) => element.frameId)
|
||||||
|
.map((element) => element.frameId!),
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetFrames = getFrameElements(elements).filter((frame) =>
|
||||||
|
selectedElementFrameIds.has(frame.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
targetFrames.forEach((frame) => {
|
||||||
if (frame) {
|
if (frame) {
|
||||||
nextElements = replaceAllElementsInFrame(
|
nextElements = replaceAllElementsInFrame(
|
||||||
nextElements,
|
nextElements,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
@@ -52,23 +51,6 @@ export const actionToggleEditMenu = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionFullScreen = register({
|
|
||||||
name: "toggleFullScreen",
|
|
||||||
viewMode: true,
|
|
||||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
|
||||||
perform: () => {
|
|
||||||
if (!isFullScreen()) {
|
|
||||||
allowFullScreen();
|
|
||||||
}
|
|
||||||
if (isFullScreen()) {
|
|
||||||
exitFullScreen();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export const actionChangeFillStyle = register({
|
|||||||
trackEvent(
|
trackEvent(
|
||||||
"element",
|
"element",
|
||||||
"changeFillStyle",
|
"changeFillStyle",
|
||||||
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
`${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import {
|
|||||||
canApplyRoundnessTypeToElement,
|
canApplyRoundnessTypeToElement,
|
||||||
getDefaultRoundnessTypeForElement,
|
getDefaultRoundnessTypeForElement,
|
||||||
isFrameElement,
|
isFrameElement,
|
||||||
|
isArrowElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { ExcalidrawTextElement } from "../element/types";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
@@ -99,16 +101,19 @@ export const actionPasteStyles = register({
|
|||||||
|
|
||||||
if (isTextElement(newElement)) {
|
if (isTextElement(newElement)) {
|
||||||
const fontSize =
|
const fontSize =
|
||||||
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
|
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
|
||||||
|
DEFAULT_FONT_SIZE;
|
||||||
const fontFamily =
|
const fontFamily =
|
||||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
|
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
|
||||||
|
DEFAULT_FONT_FAMILY;
|
||||||
newElement = newElementWith(newElement, {
|
newElement = newElementWith(newElement, {
|
||||||
fontSize,
|
fontSize,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
textAlign:
|
textAlign:
|
||||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
|
||||||
|
DEFAULT_TEXT_ALIGN,
|
||||||
lineHeight:
|
lineHeight:
|
||||||
elementStylesToCopyFrom.lineHeight ||
|
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||||
getDefaultLineHeight(fontFamily),
|
getDefaultLineHeight(fontFamily),
|
||||||
});
|
});
|
||||||
let container = null;
|
let container = null;
|
||||||
@@ -123,7 +128,10 @@ export const actionPasteStyles = register({
|
|||||||
redrawTextBoundingBox(newElement, container);
|
redrawTextBoundingBox(newElement, container);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newElement.type === "arrow") {
|
if (
|
||||||
|
newElement.type === "arrow" &&
|
||||||
|
isArrowElement(elementStylesToCopyFrom)
|
||||||
|
) {
|
||||||
newElement = newElementWith(newElement, {
|
newElement = newElementWith(newElement, {
|
||||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
|||||||
export {
|
export {
|
||||||
actionToggleCanvasMenu,
|
actionToggleCanvasMenu,
|
||||||
actionToggleEditMenu,
|
actionToggleEditMenu,
|
||||||
actionFullScreen,
|
|
||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
} from "./actionMenu";
|
} from "./actionMenu";
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const trackAction = (
|
|||||||
trackEvent(
|
trackEvent(
|
||||||
action.trackEvent.category,
|
action.trackEvent.category,
|
||||||
action.trackEvent.action || action.name,
|
action.trackEvent.action || action.name,
|
||||||
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ export const SelectedShapeActions = ({
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{!device.isMobile && renderAction("duplicateSelection")}
|
{!device.editor.isMobile && renderAction("duplicateSelection")}
|
||||||
{!device.isMobile && renderAction("deleteSelectedElements")}
|
{!device.editor.isMobile && renderAction("deleteSelectedElements")}
|
||||||
{renderAction("group")}
|
{renderAction("group")}
|
||||||
{renderAction("ungroup")}
|
{renderAction("ungroup")}
|
||||||
{showLinkIcon && renderAction("hyperlink")}
|
{showLinkIcon && renderAction("hyperlink")}
|
||||||
|
|||||||
+138
-115
@@ -74,7 +74,6 @@ import {
|
|||||||
MQ_MAX_WIDTH_LANDSCAPE,
|
MQ_MAX_WIDTH_LANDSCAPE,
|
||||||
MQ_MAX_WIDTH_PORTRAIT,
|
MQ_MAX_WIDTH_PORTRAIT,
|
||||||
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
|
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
|
||||||
MQ_SM_MAX_WIDTH,
|
|
||||||
POINTER_BUTTON,
|
POINTER_BUTTON,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
SCROLL_TIMEOUT,
|
SCROLL_TIMEOUT,
|
||||||
@@ -88,7 +87,7 @@ import {
|
|||||||
ZOOM_STEP,
|
ZOOM_STEP,
|
||||||
POINTER_EVENTS,
|
POINTER_EVENTS,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { exportCanvas, loadFromBlob } from "../data";
|
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||||
import { restore, restoreElements } from "../data/restore";
|
import { restore, restoreElements } from "../data/restore";
|
||||||
import {
|
import {
|
||||||
@@ -241,7 +240,6 @@ import {
|
|||||||
isInputLike,
|
isInputLike,
|
||||||
isToolIcon,
|
isToolIcon,
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
resolvablePromise,
|
|
||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
@@ -318,7 +316,7 @@ import { shouldShowBoundingBox } from "../element/transformHandles";
|
|||||||
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
||||||
import { Fonts } from "../scene/Fonts";
|
import { Fonts } from "../scene/Fonts";
|
||||||
import {
|
import {
|
||||||
getFrameElements,
|
getFrameChildren,
|
||||||
isCursorInFrame,
|
isCursorInFrame,
|
||||||
bindElementsToFramesAfterDuplication,
|
bindElementsToFramesAfterDuplication,
|
||||||
addElementsToFrame,
|
addElementsToFrame,
|
||||||
@@ -381,11 +379,15 @@ const AppContext = React.createContext<AppClassProperties>(null!);
|
|||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
|
|
||||||
const deviceContextInitialValue = {
|
const deviceContextInitialValue = {
|
||||||
isSmScreen: false,
|
viewport: {
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
|
isLandscape: false,
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
isMobile: false,
|
||||||
|
canFitSidebar: false,
|
||||||
|
},
|
||||||
isTouchScreen: false,
|
isTouchScreen: false,
|
||||||
canDeviceFitSidebar: false,
|
|
||||||
isLandscape: false,
|
|
||||||
};
|
};
|
||||||
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
||||||
DeviceContext.displayName = "DeviceContext";
|
DeviceContext.displayName = "DeviceContext";
|
||||||
@@ -436,6 +438,9 @@ export const useExcalidrawSetAppState = () =>
|
|||||||
export const useExcalidrawActionManager = () =>
|
export const useExcalidrawActionManager = () =>
|
||||||
useContext(ExcalidrawActionManagerContext);
|
useContext(ExcalidrawActionManagerContext);
|
||||||
|
|
||||||
|
const supportsResizeObserver =
|
||||||
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
let didTapTwice: boolean = false;
|
let didTapTwice: boolean = false;
|
||||||
let tappedTwiceTimer = 0;
|
let tappedTwiceTimer = 0;
|
||||||
let isHoldingSpace: boolean = false;
|
let isHoldingSpace: boolean = false;
|
||||||
@@ -472,7 +477,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
unmounted: boolean = false;
|
unmounted: boolean = false;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
device: Device = deviceContextInitialValue;
|
device: Device = deviceContextInitialValue;
|
||||||
detachIsMobileMqHandler?: () => void;
|
|
||||||
|
|
||||||
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
||||||
|
|
||||||
@@ -535,7 +539,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
super(props);
|
super(props);
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
const {
|
const {
|
||||||
excalidrawRef,
|
excalidrawAPI,
|
||||||
viewModeEnabled = false,
|
viewModeEnabled = false,
|
||||||
zenModeEnabled = false,
|
zenModeEnabled = false,
|
||||||
gridModeEnabled = false,
|
gridModeEnabled = false,
|
||||||
@@ -566,14 +570,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.rc = rough.canvas(this.canvas);
|
this.rc = rough.canvas(this.canvas);
|
||||||
this.renderer = new Renderer(this.scene);
|
this.renderer = new Renderer(this.scene);
|
||||||
|
|
||||||
if (excalidrawRef) {
|
if (excalidrawAPI) {
|
||||||
const readyPromise =
|
|
||||||
("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
|
|
||||||
resolvablePromise<ExcalidrawImperativeAPI>();
|
|
||||||
|
|
||||||
const api: ExcalidrawImperativeAPI = {
|
const api: ExcalidrawImperativeAPI = {
|
||||||
ready: true,
|
|
||||||
readyPromise,
|
|
||||||
updateScene: this.updateScene,
|
updateScene: this.updateScene,
|
||||||
updateLibrary: this.library.updateLibrary,
|
updateLibrary: this.library.updateLibrary,
|
||||||
addFiles: this.addFiles,
|
addFiles: this.addFiles,
|
||||||
@@ -598,12 +596,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||||
} as const;
|
} as const;
|
||||||
if (typeof excalidrawRef === "function") {
|
if (typeof excalidrawAPI === "function") {
|
||||||
excalidrawRef(api);
|
excalidrawAPI(api);
|
||||||
} else {
|
} else {
|
||||||
excalidrawRef.current = api;
|
console.error("excalidrawAPI should be a function!");
|
||||||
}
|
}
|
||||||
readyPromise.resolve(api);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.excalidrawContainerValue = {
|
this.excalidrawContainerValue = {
|
||||||
@@ -1043,12 +1040,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { x: x2 } = sceneCoordsToViewportCoords(
|
|
||||||
{ sceneX: f.x + f.width, sceneY: f.y + f.height },
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
const FRAME_NAME_GAP = 20;
|
|
||||||
const FRAME_NAME_EDIT_PADDING = 6;
|
const FRAME_NAME_EDIT_PADDING = 6;
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
@@ -1093,13 +1084,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
boxShadow: "inset 0 0 0 1px var(--color-primary)",
|
boxShadow: "inset 0 0 0 1px var(--color-primary)",
|
||||||
fontFamily: "Assistant",
|
fontFamily: "Assistant",
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
transform: `translateY(-${FRAME_NAME_EDIT_PADDING}px)`,
|
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
|
||||||
color: "var(--color-gray-80)",
|
color: "var(--color-gray-80)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
maxWidth: `${Math.min(
|
maxWidth: `${
|
||||||
x2 - x1 - FRAME_NAME_EDIT_PADDING,
|
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
|
||||||
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING,
|
}px`,
|
||||||
)}px`,
|
|
||||||
}}
|
}}
|
||||||
size={frameNameInEdit.length + 1 || 1}
|
size={frameNameInEdit.length + 1 || 1}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
@@ -1121,19 +1111,26 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
key={f.id}
|
key={f.id}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: `${y1 - FRAME_NAME_GAP - this.state.offsetTop}px`,
|
// Positioning from bottom so that we don't to either
|
||||||
left: `${
|
// calculate text height or adjust using transform (which)
|
||||||
x1 -
|
// messes up input position when editing the frame name.
|
||||||
this.state.offsetLeft -
|
// This makes the positioning deterministic and we can calculate
|
||||||
(this.state.editingFrame === f.id ? FRAME_NAME_EDIT_PADDING : 0)
|
// the same position when rendering to canvas / svg.
|
||||||
|
bottom: `${
|
||||||
|
this.state.height +
|
||||||
|
FRAME_STYLE.nameOffsetY -
|
||||||
|
y1 +
|
||||||
|
this.state.offsetTop
|
||||||
}px`,
|
}px`,
|
||||||
|
left: `${x1 - this.state.offsetLeft}px`,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
fontSize: "14px",
|
fontSize: FRAME_STYLE.nameFontSize,
|
||||||
color: isDarkTheme
|
color: isDarkTheme
|
||||||
? "var(--color-gray-60)"
|
? FRAME_STYLE.nameColorDarkTheme
|
||||||
: "var(--color-gray-50)",
|
: FRAME_STYLE.nameColorLightTheme,
|
||||||
|
lineHeight: FRAME_STYLE.nameLineHeight,
|
||||||
width: "max-content",
|
width: "max-content",
|
||||||
maxWidth: `${x2 - x1 + FRAME_NAME_EDIT_PADDING * 2}px`,
|
maxWidth: `${f.width}px`,
|
||||||
overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
|
overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
@@ -1176,24 +1173,29 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pendingImageElementId: this.state.pendingImageElementId,
|
pendingImageElementId: this.state.pendingImageElementId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldBlockPointerEvents =
|
||||||
|
!(
|
||||||
|
this.state.editingElement && isLinearElement(this.state.editingElement)
|
||||||
|
) &&
|
||||||
|
(this.state.selectionElement ||
|
||||||
|
this.state.draggingElement ||
|
||||||
|
this.state.resizingElement ||
|
||||||
|
(this.state.activeTool.type === "laser" &&
|
||||||
|
// technically we can just test on this once we make it more safe
|
||||||
|
this.state.cursorButton === "down") ||
|
||||||
|
(this.state.editingElement &&
|
||||||
|
!isTextElement(this.state.editingElement)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("excalidraw excalidraw-container", {
|
className={clsx("excalidraw excalidraw-container", {
|
||||||
"excalidraw--view-mode": this.state.viewModeEnabled,
|
"excalidraw--view-mode": this.state.viewModeEnabled,
|
||||||
"excalidraw--mobile": this.device.isMobile,
|
"excalidraw--mobile": this.device.editor.isMobile,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
["--ui-pointerEvents" as any]:
|
["--ui-pointerEvents" as any]: shouldBlockPointerEvents
|
||||||
this.state.selectionElement ||
|
? POINTER_EVENTS.disabled
|
||||||
this.state.draggingElement ||
|
: POINTER_EVENTS.enabled,
|
||||||
this.state.resizingElement ||
|
|
||||||
(this.state.activeTool.type === "laser" &&
|
|
||||||
// technically we can just test on this once we make it more safe
|
|
||||||
this.state.cursorButton === "down") ||
|
|
||||||
(this.state.editingElement &&
|
|
||||||
!isTextElement(this.state.editingElement))
|
|
||||||
? POINTER_EVENTS.disabled
|
|
||||||
: POINTER_EVENTS.enabled,
|
|
||||||
}}
|
}}
|
||||||
ref={this.excalidrawContainerRef}
|
ref={this.excalidrawContainerRef}
|
||||||
onDrop={this.handleAppOnDrop}
|
onDrop={this.handleAppOnDrop}
|
||||||
@@ -1365,7 +1367,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
public onExportImage = async (
|
public onExportImage = async (
|
||||||
type: keyof typeof EXPORT_IMAGE_TYPES,
|
type: keyof typeof EXPORT_IMAGE_TYPES,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: ExportedElements,
|
||||||
|
opts: { exportingFrame: ExcalidrawFrameElement | null },
|
||||||
) => {
|
) => {
|
||||||
trackEvent("export", type, "ui");
|
trackEvent("export", type, "ui");
|
||||||
const fileHandle = await exportCanvas(
|
const fileHandle = await exportCanvas(
|
||||||
@@ -1377,6 +1380,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
exportBackground: this.state.exportBackground,
|
exportBackground: this.state.exportBackground,
|
||||||
name: this.state.name,
|
name: this.state.name,
|
||||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||||
|
exportingFrame: opts.exportingFrame,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
@@ -1657,20 +1661,62 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private refreshDeviceState = (container: HTMLDivElement) => {
|
private isMobileBreakpoint = (width: number, height: number) => {
|
||||||
const { width, height } = container.getBoundingClientRect();
|
return (
|
||||||
|
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||||
|
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private refreshViewportBreakpoints = () => {
|
||||||
|
const container = this.excalidrawContainerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientWidth: viewportWidth, clientHeight: viewportHeight } =
|
||||||
|
document.body;
|
||||||
|
|
||||||
|
const prevViewportState = this.device.viewport;
|
||||||
|
|
||||||
|
const nextViewportState = updateObject(prevViewportState, {
|
||||||
|
isLandscape: viewportWidth > viewportHeight,
|
||||||
|
isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prevViewportState !== nextViewportState) {
|
||||||
|
this.device = { ...this.device, viewport: nextViewportState };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private refreshEditorBreakpoints = () => {
|
||||||
|
const container = this.excalidrawContainerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width: editorWidth, height: editorHeight } =
|
||||||
|
container.getBoundingClientRect();
|
||||||
|
|
||||||
const sidebarBreakpoint =
|
const sidebarBreakpoint =
|
||||||
this.props.UIOptions.dockedSidebarBreakpoint != null
|
this.props.UIOptions.dockedSidebarBreakpoint != null
|
||||||
? this.props.UIOptions.dockedSidebarBreakpoint
|
? this.props.UIOptions.dockedSidebarBreakpoint
|
||||||
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
|
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
|
||||||
this.device = updateObject(this.device, {
|
|
||||||
isLandscape: width > height,
|
const prevEditorState = this.device.editor;
|
||||||
isSmScreen: width < MQ_SM_MAX_WIDTH,
|
|
||||||
isMobile:
|
const nextEditorState = updateObject(prevEditorState, {
|
||||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
isMobile: this.isMobileBreakpoint(editorWidth, editorHeight),
|
||||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE),
|
canFitSidebar: editorWidth > sidebarBreakpoint,
|
||||||
canDeviceFitSidebar: width > sidebarBreakpoint,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (prevEditorState !== nextEditorState) {
|
||||||
|
this.device = { ...this.device, editor: nextEditorState };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
public async componentDidMount() {
|
public async componentDidMount() {
|
||||||
@@ -1712,52 +1758,21 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.excalidrawContainerRef.current &&
|
|
||||||
// bounding rects don't work in tests so updating
|
// bounding rects don't work in tests so updating
|
||||||
// the state on init would result in making the test enviro run
|
// the state on init would result in making the test enviro run
|
||||||
// in mobile breakpoint (0 width/height), making everything fail
|
// in mobile breakpoint (0 width/height), making everything fail
|
||||||
!isTestEnv()
|
!isTestEnv()
|
||||||
) {
|
) {
|
||||||
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
this.refreshViewportBreakpoints();
|
||||||
|
this.refreshEditorBreakpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
|
if (supportsResizeObserver && this.excalidrawContainerRef.current) {
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
// recompute device dimensions state
|
this.refreshEditorBreakpoints();
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
this.refreshDeviceState(this.excalidrawContainerRef.current!);
|
|
||||||
// refresh offsets
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
this.updateDOMRect();
|
this.updateDOMRect();
|
||||||
});
|
});
|
||||||
this.resizeObserver?.observe(this.excalidrawContainerRef.current);
|
this.resizeObserver?.observe(this.excalidrawContainerRef.current);
|
||||||
} else if (window.matchMedia) {
|
|
||||||
const mdScreenQuery = window.matchMedia(
|
|
||||||
`(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`,
|
|
||||||
);
|
|
||||||
const smScreenQuery = window.matchMedia(
|
|
||||||
`(max-width: ${MQ_SM_MAX_WIDTH}px)`,
|
|
||||||
);
|
|
||||||
const canDeviceFitSidebarMediaQuery = window.matchMedia(
|
|
||||||
`(min-width: ${
|
|
||||||
// NOTE this won't update if a different breakpoint is supplied
|
|
||||||
// after mount
|
|
||||||
this.props.UIOptions.dockedSidebarBreakpoint != null
|
|
||||||
? this.props.UIOptions.dockedSidebarBreakpoint
|
|
||||||
: MQ_RIGHT_SIDEBAR_MIN_WIDTH
|
|
||||||
}px)`,
|
|
||||||
);
|
|
||||||
const handler = () => {
|
|
||||||
this.excalidrawContainerRef.current!.getBoundingClientRect();
|
|
||||||
this.device = updateObject(this.device, {
|
|
||||||
isSmScreen: smScreenQuery.matches,
|
|
||||||
isMobile: mdScreenQuery.matches,
|
|
||||||
canDeviceFitSidebar: canDeviceFitSidebarMediaQuery.matches,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
mdScreenQuery.addListener(handler);
|
|
||||||
this.detachIsMobileMqHandler = () =>
|
|
||||||
mdScreenQuery.removeListener(handler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search.slice(1));
|
const searchParams = new URLSearchParams(window.location.search.slice(1));
|
||||||
@@ -1802,6 +1817,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene
|
this.scene
|
||||||
.getElementsIncludingDeleted()
|
.getElementsIncludingDeleted()
|
||||||
.forEach((element) => ShapeCache.delete(element));
|
.forEach((element) => ShapeCache.delete(element));
|
||||||
|
this.refreshViewportBreakpoints();
|
||||||
|
this.updateDOMRect();
|
||||||
|
if (!supportsResizeObserver) {
|
||||||
|
this.refreshEditorBreakpoints();
|
||||||
|
}
|
||||||
this.setState({});
|
this.setState({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1855,7 +1875,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.detachIsMobileMqHandler?.();
|
|
||||||
window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
|
window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1940,11 +1959,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.excalidrawContainerRef.current &&
|
|
||||||
prevProps.UIOptions.dockedSidebarBreakpoint !==
|
prevProps.UIOptions.dockedSidebarBreakpoint !==
|
||||||
this.props.UIOptions.dockedSidebarBreakpoint
|
this.props.UIOptions.dockedSidebarBreakpoint
|
||||||
) {
|
) {
|
||||||
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
this.refreshEditorBreakpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -2410,7 +2428,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// from library, not when pasting from clipboard. Alas.
|
// from library, not when pasting from clipboard. Alas.
|
||||||
openSidebar:
|
openSidebar:
|
||||||
this.state.openSidebar &&
|
this.state.openSidebar &&
|
||||||
this.device.canDeviceFitSidebar &&
|
this.device.editor.canFitSidebar &&
|
||||||
jotaiStore.get(isSidebarDockedAtom)
|
jotaiStore.get(isSidebarDockedAtom)
|
||||||
? this.state.openSidebar
|
? this.state.openSidebar
|
||||||
: null,
|
: null,
|
||||||
@@ -2624,7 +2642,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
!isPlainPaste &&
|
!isPlainPaste &&
|
||||||
textElements.length > 1 &&
|
textElements.length > 1 &&
|
||||||
PLAIN_PASTE_TOAST_SHOWN === false &&
|
PLAIN_PASTE_TOAST_SHOWN === false &&
|
||||||
!this.device.isMobile
|
!this.device.editor.isMobile
|
||||||
) {
|
) {
|
||||||
this.setToast({
|
this.setToast({
|
||||||
message: t("toast.pasteAsSingleElement", {
|
message: t("toast.pasteAsSingleElement", {
|
||||||
@@ -2658,7 +2676,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
trackEvent(
|
trackEvent(
|
||||||
"toolbar",
|
"toolbar",
|
||||||
"toggleLock",
|
"toggleLock",
|
||||||
`${source} (${this.device.isMobile ? "mobile" : "desktop"})`,
|
`${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.setState((prevState) => {
|
this.setState((prevState) => {
|
||||||
@@ -2698,7 +2716,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
togglePenMode = (force?: boolean) => {
|
togglePenMode = (force: boolean | null) => {
|
||||||
this.setState((prevState) => {
|
this.setState((prevState) => {
|
||||||
return {
|
return {
|
||||||
penMode: force ?? !prevState.penMode,
|
penMode: force ?? !prevState.penMode,
|
||||||
@@ -3153,7 +3171,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
trackEvent(
|
trackEvent(
|
||||||
"toolbar",
|
"toolbar",
|
||||||
shape,
|
shape,
|
||||||
`keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
|
`keyboard (${
|
||||||
|
this.device.editor.isMobile ? "mobile" : "desktop"
|
||||||
|
})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.setActiveTool({ type: shape });
|
this.setActiveTool({ type: shape });
|
||||||
@@ -3887,7 +3907,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
element,
|
element,
|
||||||
this.state,
|
this.state,
|
||||||
[scenePointer.x, scenePointer.y],
|
[scenePointer.x, scenePointer.y],
|
||||||
this.device.isMobile,
|
this.device.editor.isMobile,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -3919,7 +3939,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.hitLinkElement,
|
this.hitLinkElement,
|
||||||
this.state,
|
this.state,
|
||||||
[lastPointerDownCoords.x, lastPointerDownCoords.y],
|
[lastPointerDownCoords.x, lastPointerDownCoords.y],
|
||||||
this.device.isMobile,
|
this.device.editor.isMobile,
|
||||||
);
|
);
|
||||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||||
this.lastPointerUpEvent!,
|
this.lastPointerUpEvent!,
|
||||||
@@ -3929,7 +3949,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.hitLinkElement,
|
this.hitLinkElement,
|
||||||
this.state,
|
this.state,
|
||||||
[lastPointerUpCoords.x, lastPointerUpCoords.y],
|
[lastPointerUpCoords.x, lastPointerUpCoords.y],
|
||||||
this.device.isMobile,
|
this.device.editor.isMobile,
|
||||||
);
|
);
|
||||||
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
||||||
let url = this.hitLinkElement.link;
|
let url = this.hitLinkElement.link;
|
||||||
@@ -4791,7 +4811,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
const clicklength =
|
const clicklength =
|
||||||
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
|
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
|
||||||
if (this.device.isMobile && clicklength < 300) {
|
if (this.device.editor.isMobile && clicklength < 300) {
|
||||||
const hitElement = this.getElementAtPosition(
|
const hitElement = this.getElementAtPosition(
|
||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
@@ -5309,7 +5329,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
// if hitElement is frame, deselect all of its elements if they are selected
|
// if hitElement is frame, deselect all of its elements if they are selected
|
||||||
if (hitElement.type === "frame") {
|
if (hitElement.type === "frame") {
|
||||||
getFrameElements(
|
getFrameChildren(
|
||||||
previouslySelectedElements,
|
previouslySelectedElements,
|
||||||
hitElement.id,
|
hitElement.id,
|
||||||
).forEach((element) => {
|
).forEach((element) => {
|
||||||
@@ -6848,10 +6868,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
topLayerFrame &&
|
topLayerFrame &&
|
||||||
!this.state.selectedElementIds[topLayerFrame.id]
|
!this.state.selectedElementIds[topLayerFrame.id]
|
||||||
) {
|
) {
|
||||||
|
const processedGroupIds = new Map<string, boolean>();
|
||||||
const elementsToAdd = selectedElements.filter(
|
const elementsToAdd = selectedElements.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
element.frameId !== topLayerFrame.id &&
|
element.frameId !== topLayerFrame.id &&
|
||||||
isElementInFrame(element, nextElements, this.state),
|
isElementInFrame(element, nextElements, this.state, {
|
||||||
|
processedGroupIds,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.state.editingGroupId) {
|
if (this.state.editingGroupId) {
|
||||||
@@ -8173,7 +8196,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
selectedFrames.forEach((frame) => {
|
selectedFrames.forEach((frame) => {
|
||||||
const elementsInFrame = getFrameElements(
|
const elementsInFrame = getFrameChildren(
|
||||||
this.scene.getNonDeletedElements(),
|
this.scene.getNonDeletedElements(),
|
||||||
frame.id,
|
frame.id,
|
||||||
);
|
);
|
||||||
@@ -8243,7 +8266,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const elementsToHighlight = new Set<ExcalidrawElement>();
|
const elementsToHighlight = new Set<ExcalidrawElement>();
|
||||||
selectedFrames.forEach((frame) => {
|
selectedFrames.forEach((frame) => {
|
||||||
const elementsInFrame = getFrameElements(
|
const elementsInFrame = getFrameChildren(
|
||||||
this.scene.getNonDeletedElements(),
|
this.scene.getNonDeletedElements(),
|
||||||
frame.id,
|
frame.id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const ColorInput = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* TODO reenable on mobile with a better UX */}
|
{/* TODO reenable on mobile with a better UX */}
|
||||||
{!device.isMobile && (
|
{!device.editor.isMobile && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const ColorPickerPopupContent = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const { isMobile, isLandscape } = useDevice();
|
const device = useDevice();
|
||||||
|
|
||||||
const colorInputJSX = (
|
const colorInputJSX = (
|
||||||
<div>
|
<div>
|
||||||
@@ -136,8 +136,16 @@ const ColorPickerPopupContent = ({
|
|||||||
updateData({ openPopup: null });
|
updateData({ openPopup: null });
|
||||||
setActiveColorPickerSection(null);
|
setActiveColorPickerSection(null);
|
||||||
}}
|
}}
|
||||||
side={isMobile && !isLandscape ? "bottom" : "right"}
|
side={
|
||||||
align={isMobile && !isLandscape ? "center" : "start"}
|
device.editor.isMobile && !device.viewport.isLandscape
|
||||||
|
? "bottom"
|
||||||
|
: "right"
|
||||||
|
}
|
||||||
|
align={
|
||||||
|
device.editor.isMobile && !device.viewport.isLandscape
|
||||||
|
? "center"
|
||||||
|
: "start"
|
||||||
|
}
|
||||||
alignOffset={-16}
|
alignOffset={-16}
|
||||||
sideOffset={20}
|
sideOffset={20}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -33,14 +33,16 @@
|
|||||||
color: var(--color-gray-40);
|
color: var(--color-gray-40);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
|
||||||
top: 1.25rem;
|
|
||||||
right: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Dialog--fullscreen {
|
||||||
|
.Dialog__close {
|
||||||
|
top: 1.25rem;
|
||||||
|
right: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
const [lastActiveElement] = useState(document.activeElement);
|
const [lastActiveElement] = useState(document.activeElement);
|
||||||
const { id } = useExcalidrawContainer();
|
const { id } = useExcalidrawContainer();
|
||||||
const device = useDevice();
|
const isFullscreen = useDevice().viewport.isMobile;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!islandNode) {
|
if (!islandNode) {
|
||||||
@@ -101,7 +101,9 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className={clsx("Dialog", props.className)}
|
className={clsx("Dialog", props.className, {
|
||||||
|
"Dialog--fullscreen": isFullscreen,
|
||||||
|
})}
|
||||||
labelledBy="dialog-title"
|
labelledBy="dialog-title"
|
||||||
maxWidth={getDialogSize(props.size)}
|
maxWidth={getDialogSize(props.size)}
|
||||||
onCloseRequest={onClose}
|
onCloseRequest={onClose}
|
||||||
@@ -119,7 +121,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
title={t("buttons.close")}
|
title={t("buttons.close")}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{device.isMobile ? back : CloseIcon}
|
{isFullscreen ? back : CloseIcon}
|
||||||
</button>
|
</button>
|
||||||
<div className="Dialog__content">{props.children}</div>
|
<div className="Dialog__content">{props.children}</div>
|
||||||
</Island>
|
</Island>
|
||||||
|
|||||||
@@ -254,7 +254,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("helpDialog.movePageLeftRight")}
|
label={t("helpDialog.movePageLeftRight")}
|
||||||
shortcuts={["Shift+PgUp/PgDn"]}
|
shortcuts={["Shift+PgUp/PgDn"]}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("buttons.zenMode")}
|
label={t("buttons.zenMode")}
|
||||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
|||||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (appState.openSidebar && !device.canDeviceFitSidebar) {
|
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { canvasToBlob } from "../data/blob";
|
|||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas } from "../packages/utils";
|
import { exportToCanvas } from "../packages/utils";
|
||||||
|
|
||||||
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
||||||
@@ -34,6 +34,8 @@ import { Tooltip } from "./Tooltip";
|
|||||||
import "./ImageExportDialog.scss";
|
import "./ImageExportDialog.scss";
|
||||||
import { useAppProps } from "./App";
|
import { useAppProps } from "./App";
|
||||||
import { FilledButton } from "./FilledButton";
|
import { FilledButton } from "./FilledButton";
|
||||||
|
import { cloneJSON } from "../utils";
|
||||||
|
import { prepareElementsForExport } from "../data";
|
||||||
|
|
||||||
const supportsContextFilters =
|
const supportsContextFilters =
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||||
@@ -51,44 +53,47 @@ export const ErrorCanvasPreview = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ImageExportModalProps = {
|
type ImageExportModalProps = {
|
||||||
appState: UIAppState;
|
appStateSnapshot: Readonly<UIAppState>;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
onExportImage: AppClassProperties["onExportImage"];
|
onExportImage: AppClassProperties["onExportImage"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImageExportModal = ({
|
const ImageExportModal = ({
|
||||||
appState,
|
appStateSnapshot,
|
||||||
elements,
|
elementsSnapshot,
|
||||||
files,
|
files,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportImage,
|
onExportImage,
|
||||||
}: ImageExportModalProps) => {
|
}: ImageExportModalProps) => {
|
||||||
|
const hasSelection = isSomeElementSelected(
|
||||||
|
elementsSnapshot,
|
||||||
|
appStateSnapshot,
|
||||||
|
);
|
||||||
|
|
||||||
const appProps = useAppProps();
|
const appProps = useAppProps();
|
||||||
const [projectName, setProjectName] = useState(appState.name);
|
const [projectName, setProjectName] = useState(appStateSnapshot.name);
|
||||||
|
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
|
||||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
|
||||||
|
|
||||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
|
||||||
const [exportWithBackground, setExportWithBackground] = useState(
|
const [exportWithBackground, setExportWithBackground] = useState(
|
||||||
appState.exportBackground,
|
appStateSnapshot.exportBackground,
|
||||||
);
|
);
|
||||||
const [exportDarkMode, setExportDarkMode] = useState(
|
const [exportDarkMode, setExportDarkMode] = useState(
|
||||||
appState.exportWithDarkMode,
|
appStateSnapshot.exportWithDarkMode,
|
||||||
);
|
);
|
||||||
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
|
const [embedScene, setEmbedScene] = useState(
|
||||||
const [exportScale, setExportScale] = useState(appState.exportScale);
|
appStateSnapshot.exportEmbedScene,
|
||||||
|
);
|
||||||
|
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
|
||||||
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const exportedElements = exportSelected
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
? getSelectedElements(elements, appState, {
|
elementsSnapshot,
|
||||||
includeBoundTextElement: true,
|
appStateSnapshot,
|
||||||
includeElementsInFrames: true,
|
exportSelectionOnly,
|
||||||
})
|
);
|
||||||
: elements;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previewNode = previewRef.current;
|
const previewNode = previewRef.current;
|
||||||
@@ -102,10 +107,18 @@ const ImageExportModal = ({
|
|||||||
}
|
}
|
||||||
exportToCanvas({
|
exportToCanvas({
|
||||||
elements: exportedElements,
|
elements: exportedElements,
|
||||||
appState,
|
appState: {
|
||||||
|
...appStateSnapshot,
|
||||||
|
name: projectName,
|
||||||
|
exportBackground: exportWithBackground,
|
||||||
|
exportWithDarkMode: exportDarkMode,
|
||||||
|
exportScale,
|
||||||
|
exportEmbedScene: embedScene,
|
||||||
|
},
|
||||||
files,
|
files,
|
||||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||||
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
||||||
|
exportingFrame,
|
||||||
})
|
})
|
||||||
.then((canvas) => {
|
.then((canvas) => {
|
||||||
setRenderError(null);
|
setRenderError(null);
|
||||||
@@ -119,7 +132,17 @@ const ImageExportModal = ({
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
setRenderError(error);
|
setRenderError(error);
|
||||||
});
|
});
|
||||||
}, [appState, files, exportedElements]);
|
}, [
|
||||||
|
appStateSnapshot,
|
||||||
|
files,
|
||||||
|
exportedElements,
|
||||||
|
exportingFrame,
|
||||||
|
projectName,
|
||||||
|
exportWithBackground,
|
||||||
|
exportDarkMode,
|
||||||
|
exportScale,
|
||||||
|
embedScene,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ImageExportModal">
|
<div className="ImageExportModal">
|
||||||
@@ -136,7 +159,8 @@ const ImageExportModal = ({
|
|||||||
value={projectName}
|
value={projectName}
|
||||||
style={{ width: "30ch" }}
|
style={{ width: "30ch" }}
|
||||||
disabled={
|
disabled={
|
||||||
typeof appProps.name !== "undefined" || appState.viewModeEnabled
|
typeof appProps.name !== "undefined" ||
|
||||||
|
appStateSnapshot.viewModeEnabled
|
||||||
}
|
}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setProjectName(event.target.value);
|
setProjectName(event.target.value);
|
||||||
@@ -152,16 +176,16 @@ const ImageExportModal = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="ImageExportModal__settings">
|
<div className="ImageExportModal__settings">
|
||||||
<h3>{t("imageExportDialog.header")}</h3>
|
<h3>{t("imageExportDialog.header")}</h3>
|
||||||
{someElementIsSelected && (
|
{hasSelection && (
|
||||||
<ExportSetting
|
<ExportSetting
|
||||||
label={t("imageExportDialog.label.onlySelected")}
|
label={t("imageExportDialog.label.onlySelected")}
|
||||||
name="exportOnlySelected"
|
name="exportOnlySelected"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
name="exportOnlySelected"
|
name="exportOnlySelected"
|
||||||
checked={exportSelected}
|
checked={exportSelectionOnly}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
setExportSelected(checked);
|
setExportSelectionOnly(checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ExportSetting>
|
</ExportSetting>
|
||||||
@@ -243,7 +267,9 @@ const ImageExportModal = ({
|
|||||||
className="ImageExportModal__settings__buttons__button"
|
className="ImageExportModal__settings__buttons__button"
|
||||||
label={t("imageExportDialog.title.exportToPng")}
|
label={t("imageExportDialog.title.exportToPng")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
|
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
|
||||||
|
exportingFrame,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
startIcon={downloadIcon}
|
startIcon={downloadIcon}
|
||||||
>
|
>
|
||||||
@@ -253,7 +279,9 @@ const ImageExportModal = ({
|
|||||||
className="ImageExportModal__settings__buttons__button"
|
className="ImageExportModal__settings__buttons__button"
|
||||||
label={t("imageExportDialog.title.exportToSvg")}
|
label={t("imageExportDialog.title.exportToSvg")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
|
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
|
||||||
|
exportingFrame,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
startIcon={downloadIcon}
|
startIcon={downloadIcon}
|
||||||
>
|
>
|
||||||
@@ -264,7 +292,9 @@ const ImageExportModal = ({
|
|||||||
className="ImageExportModal__settings__buttons__button"
|
className="ImageExportModal__settings__buttons__button"
|
||||||
label={t("imageExportDialog.title.copyPngToClipboard")}
|
label={t("imageExportDialog.title.copyPngToClipboard")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
|
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
|
||||||
|
exportingFrame,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
startIcon={copyIcon}
|
startIcon={copyIcon}
|
||||||
>
|
>
|
||||||
@@ -325,15 +355,20 @@ export const ImageExportDialog = ({
|
|||||||
onExportImage: AppClassProperties["onExportImage"];
|
onExportImage: AppClassProperties["onExportImage"];
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
if (appState.openDialog !== "imageExport") {
|
// we need to take a snapshot so that the exported state can't be modified
|
||||||
return null;
|
// while the dialog is open
|
||||||
}
|
const [{ appStateSnapshot, elementsSnapshot }] = useState(() => {
|
||||||
|
return {
|
||||||
|
appStateSnapshot: cloneJSON(appState),
|
||||||
|
elementsSnapshot: cloneJSON(elements),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
|
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
|
||||||
<ImageExportModal
|
<ImageExportModal
|
||||||
elements={elements}
|
elementsSnapshot={elementsSnapshot}
|
||||||
appState={appState}
|
appStateSnapshot={appStateSnapshot}
|
||||||
files={files}
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportImage={onExportImage}
|
onExportImage={onExportImage}
|
||||||
|
|||||||
+14
-11
@@ -66,7 +66,7 @@ interface LayerUIProps {
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onHandToolToggle: () => void;
|
onHandToolToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||||
@@ -161,7 +161,10 @@ const LayerUI = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderImageExportDialog = () => {
|
const renderImageExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.saveAsImage) {
|
if (
|
||||||
|
!UIOptions.canvasActions.saveAsImage ||
|
||||||
|
appState.openDialog !== "imageExport"
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +249,7 @@ const LayerUI = ({
|
|||||||
>
|
>
|
||||||
<HintViewer
|
<HintViewer
|
||||||
appState={appState}
|
appState={appState}
|
||||||
isMobile={device.isMobile}
|
isMobile={device.editor.isMobile}
|
||||||
device={device}
|
device={device}
|
||||||
app={app}
|
app={app}
|
||||||
/>
|
/>
|
||||||
@@ -255,7 +258,7 @@ const LayerUI = ({
|
|||||||
<PenModeButton
|
<PenModeButton
|
||||||
zenModeEnabled={appState.zenModeEnabled}
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
onChange={onPenModeToggle}
|
onChange={() => onPenModeToggle(null)}
|
||||||
title={t("toolBar.penMode")}
|
title={t("toolBar.penMode")}
|
||||||
penDetected={appState.penDetected}
|
penDetected={appState.penDetected}
|
||||||
/>
|
/>
|
||||||
@@ -314,7 +317,7 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<UserList collaborators={appState.collaborators} />
|
<UserList collaborators={appState.collaborators} />
|
||||||
{renderTopRightUI?.(device.isMobile, appState)}
|
{renderTopRightUI?.(device.editor.isMobile, appState)}
|
||||||
{!appState.viewModeEnabled &&
|
{!appState.viewModeEnabled &&
|
||||||
// hide button when sidebar docked
|
// hide button when sidebar docked
|
||||||
(!isSidebarDocked ||
|
(!isSidebarDocked ||
|
||||||
@@ -335,7 +338,7 @@ const LayerUI = ({
|
|||||||
trackEvent(
|
trackEvent(
|
||||||
"sidebar",
|
"sidebar",
|
||||||
`toggleDock (${docked ? "dock" : "undock"})`,
|
`toggleDock (${docked ? "dock" : "undock"})`,
|
||||||
`(${device.isMobile ? "mobile" : "desktop"})`,
|
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -363,7 +366,7 @@ const LayerUI = ({
|
|||||||
trackEvent(
|
trackEvent(
|
||||||
"sidebar",
|
"sidebar",
|
||||||
`${DEFAULT_SIDEBAR.name} (open)`,
|
`${DEFAULT_SIDEBAR.name} (open)`,
|
||||||
`button (${device.isMobile ? "mobile" : "desktop"})`,
|
`button (${device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -380,7 +383,7 @@ const LayerUI = ({
|
|||||||
{appState.errorMessage}
|
{appState.errorMessage}
|
||||||
</ErrorDialog>
|
</ErrorDialog>
|
||||||
)}
|
)}
|
||||||
{eyeDropperState && !device.isMobile && (
|
{eyeDropperState && !device.editor.isMobile && (
|
||||||
<EyeDropper
|
<EyeDropper
|
||||||
colorPickerType={eyeDropperState.colorPickerType}
|
colorPickerType={eyeDropperState.colorPickerType}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
@@ -450,7 +453,7 @@ const LayerUI = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{device.isMobile && (
|
{device.editor.isMobile && (
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
app={app}
|
app={app}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
@@ -469,14 +472,14 @@ const LayerUI = ({
|
|||||||
renderWelcomeScreen={renderWelcomeScreen}
|
renderWelcomeScreen={renderWelcomeScreen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!device.isMobile && (
|
{!device.editor.isMobile && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="layer-ui__wrapper"
|
className="layer-ui__wrapper"
|
||||||
style={
|
style={
|
||||||
appState.openSidebar &&
|
appState.openSidebar &&
|
||||||
isSidebarDocked &&
|
isSidebarDocked &&
|
||||||
device.canDeviceFitSidebar
|
device.editor.canFitSidebar
|
||||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const LibraryUnit = memo(
|
|||||||
}, [svg]);
|
}, [svg]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useDevice().isMobile;
|
const isMobile = useDevice().editor.isMobile;
|
||||||
const adder = isPending && (
|
const adder = isPending && (
|
||||||
<div className="library-unit__adder">{PlusIcon}</div>
|
<div className="library-unit__adder">{PlusIcon}</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type MobileMenuProps = {
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onHandToolToggle: () => void;
|
onHandToolToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||||
|
|
||||||
renderTopRightUI?: (
|
renderTopRightUI?: (
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
@@ -94,7 +94,7 @@ export const MobileMenu = ({
|
|||||||
)}
|
)}
|
||||||
<PenModeButton
|
<PenModeButton
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
onChange={onPenModeToggle}
|
onChange={() => onPenModeToggle(null)}
|
||||||
title={t("toolBar.penMode")}
|
title={t("toolBar.penMode")}
|
||||||
isMobile
|
isMobile
|
||||||
penDetected={appState.penDetected}
|
penDetected={appState.penDetected}
|
||||||
|
|||||||
@@ -59,12 +59,6 @@
|
|||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
|
||||||
max-width: 100%;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes Modal__background__fade-in {
|
@keyframes Modal__background__fade-in {
|
||||||
@@ -105,7 +99,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include isMobile {
|
.Dialog--fullscreen {
|
||||||
.Modal {
|
.Modal {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -116,6 +110,9 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ export const SidebarInner = forwardRef(
|
|||||||
if ((event.target as Element).closest(".sidebar-trigger")) {
|
if ((event.target as Element).closest(".sidebar-trigger")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!docked || !device.canDeviceFitSidebar) {
|
if (!docked || !device.editor.canFitSidebar) {
|
||||||
closeLibrary();
|
closeLibrary();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[closeLibrary, docked, device.canDeviceFitSidebar],
|
[closeLibrary, docked, device.editor.canFitSidebar],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ export const SidebarInner = forwardRef(
|
|||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
event.key === KEYS.ESCAPE &&
|
event.key === KEYS.ESCAPE &&
|
||||||
(!docked || !device.canDeviceFitSidebar)
|
(!docked || !device.editor.canFitSidebar)
|
||||||
) {
|
) {
|
||||||
closeLibrary();
|
closeLibrary();
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ export const SidebarInner = forwardRef(
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
|
}, [closeLibrary, docked, device.editor.canFitSidebar]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Island
|
<Island
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const SidebarHeader = ({
|
|||||||
const props = useContext(SidebarPropsContext);
|
const props = useContext(SidebarPropsContext);
|
||||||
|
|
||||||
const renderDockButton = !!(
|
const renderDockButton = !!(
|
||||||
device.canDeviceFitSidebar && props.shouldRenderDockButton
|
device.editor.canFitSidebar && props.shouldRenderDockButton
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const MenuContent = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const classNames = clsx(`dropdown-menu ${className}`, {
|
const classNames = clsx(`dropdown-menu ${className}`, {
|
||||||
"dropdown-menu--mobile": device.isMobile,
|
"dropdown-menu--mobile": device.editor.isMobile,
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +43,7 @@ const MenuContent = ({
|
|||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||||
{device.isMobile ? (
|
{device.editor.isMobile ? (
|
||||||
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||||
) : (
|
) : (
|
||||||
<Island
|
<Island
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const MenuItemContent = ({
|
|||||||
<>
|
<>
|
||||||
<div className="dropdown-menu-item__icon">{icon}</div>
|
<div className="dropdown-menu-item__icon">{icon}</div>
|
||||||
<div className="dropdown-menu-item__text">{children}</div>
|
<div className="dropdown-menu-item__text">{children}</div>
|
||||||
{shortcut && !device.isMobile && (
|
{shortcut && !device.editor.isMobile && (
|
||||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const MenuTrigger = ({
|
|||||||
`dropdown-menu-button ${className}`,
|
`dropdown-menu-button ${className}`,
|
||||||
"zen-mode-transition",
|
"zen-mode-transition",
|
||||||
{
|
{
|
||||||
"dropdown-menu-button--mobile": device.isMobile,
|
"dropdown-menu-button--mobile": device.editor.isMobile,
|
||||||
},
|
},
|
||||||
).trim();
|
).trim();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const MainMenu = Object.assign(
|
|||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const onClickOutside = device.isMobile
|
const onClickOutside = device.editor.isMobile
|
||||||
? undefined
|
? undefined
|
||||||
: () => setAppState({ openMenu: null });
|
: () => setAppState({ openMenu: null });
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ const MainMenu = Object.assign(
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{device.isMobile && appState.collaborators.size > 0 && (
|
{device.editor.isMobile && appState.collaborators.size > 0 && (
|
||||||
<fieldset className="UserList-Wrapper">
|
<fieldset className="UserList-Wrapper">
|
||||||
<legend>{t("labels.collaborators")}</legend>
|
<legend>{t("labels.collaborators")}</legend>
|
||||||
<UserList
|
<UserList
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const WelcomeScreenMenuItemContent = ({
|
|||||||
<>
|
<>
|
||||||
<div className="welcome-screen-menu-item__icon">{icon}</div>
|
<div className="welcome-screen-menu-item__icon">{icon}</div>
|
||||||
<div className="welcome-screen-menu-item__text">{children}</div>
|
<div className="welcome-screen-menu-item__text">{children}</div>
|
||||||
{shortcut && !device.isMobile && (
|
{shortcut && !device.editor.isMobile && (
|
||||||
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
|
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+7
-3
@@ -105,6 +105,7 @@ export const FONT_FAMILY = {
|
|||||||
Virgil: 1,
|
Virgil: 1,
|
||||||
Helvetica: 2,
|
Helvetica: 2,
|
||||||
Cascadia: 3,
|
Cascadia: 3,
|
||||||
|
Assistant: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const THEME = {
|
export const THEME = {
|
||||||
@@ -114,13 +115,18 @@ export const THEME = {
|
|||||||
|
|
||||||
export const FRAME_STYLE = {
|
export const FRAME_STYLE = {
|
||||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||||
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
|
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
|
||||||
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
|
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
|
||||||
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
|
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
|
||||||
roughness: 0 as ExcalidrawElement["roughness"],
|
roughness: 0 as ExcalidrawElement["roughness"],
|
||||||
roundness: null as ExcalidrawElement["roundness"],
|
roundness: null as ExcalidrawElement["roundness"],
|
||||||
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
|
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
|
||||||
radius: 8,
|
radius: 8,
|
||||||
|
nameOffsetY: 3,
|
||||||
|
nameColorLightTheme: "#999999",
|
||||||
|
nameColorDarkTheme: "#7a7a7a",
|
||||||
|
nameFontSize: 14,
|
||||||
|
nameLineHeight: 1.25,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||||
@@ -220,8 +226,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
|
|
||||||
// breakpoints
|
// breakpoints
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// sm screen
|
|
||||||
export const MQ_SM_MAX_WIDTH = 640;
|
|
||||||
// md screen
|
// md screen
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||||
|
|||||||
+66
-2
@@ -3,11 +3,19 @@ import {
|
|||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
|
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { getNonDeletedElements, isFrameElement } from "../element";
|
||||||
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { elementsOverlappingBBox } from "../packages/withinBounds";
|
||||||
|
import { isSomeElementSelected, getSelectedElements } from "../scene";
|
||||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
|
import { cloneJSON } from "../utils";
|
||||||
import { canvasToBlob } from "./blob";
|
import { canvasToBlob } from "./blob";
|
||||||
import { fileSave, FileSystemHandle } from "./filesystem";
|
import { fileSave, FileSystemHandle } from "./filesystem";
|
||||||
import { serializeAsJSON } from "./json";
|
import { serializeAsJSON } from "./json";
|
||||||
@@ -15,9 +23,61 @@ import { serializeAsJSON } from "./json";
|
|||||||
export { loadFromBlob } from "./blob";
|
export { loadFromBlob } from "./blob";
|
||||||
export { loadFromJSON, saveAsJSON } from "./json";
|
export { loadFromJSON, saveAsJSON } from "./json";
|
||||||
|
|
||||||
|
export type ExportedElements = readonly NonDeletedExcalidrawElement[] & {
|
||||||
|
_brand: "exportedElements";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prepareElementsForExport = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
{ selectedElementIds }: Pick<AppState, "selectedElementIds">,
|
||||||
|
exportSelectionOnly: boolean,
|
||||||
|
) => {
|
||||||
|
elements = getNonDeletedElements(elements);
|
||||||
|
|
||||||
|
const isExportingSelection =
|
||||||
|
exportSelectionOnly &&
|
||||||
|
isSomeElementSelected(elements, { selectedElementIds });
|
||||||
|
|
||||||
|
let exportingFrame: ExcalidrawFrameElement | null = null;
|
||||||
|
let exportedElements = isExportingSelection
|
||||||
|
? getSelectedElements(
|
||||||
|
elements,
|
||||||
|
{ selectedElementIds },
|
||||||
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: elements;
|
||||||
|
|
||||||
|
if (isExportingSelection) {
|
||||||
|
if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
|
||||||
|
exportingFrame = exportedElements[0];
|
||||||
|
exportedElements = elementsOverlappingBBox({
|
||||||
|
elements,
|
||||||
|
bounds: exportingFrame,
|
||||||
|
type: "overlap",
|
||||||
|
});
|
||||||
|
} else if (exportedElements.length > 1) {
|
||||||
|
exportedElements = getSelectedElements(
|
||||||
|
elements,
|
||||||
|
{ selectedElementIds },
|
||||||
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportingFrame,
|
||||||
|
exportedElements: cloneJSON(exportedElements) as ExportedElements,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const exportCanvas = async (
|
export const exportCanvas = async (
|
||||||
type: Omit<ExportType, "backend">,
|
type: Omit<ExportType, "backend">,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: ExportedElements,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
{
|
{
|
||||||
@@ -26,12 +86,14 @@ export const exportCanvas = async (
|
|||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
name,
|
name,
|
||||||
fileHandle = null,
|
fileHandle = null,
|
||||||
|
exportingFrame = null,
|
||||||
}: {
|
}: {
|
||||||
exportBackground: boolean;
|
exportBackground: boolean;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
name: string;
|
name: string;
|
||||||
fileHandle?: FileSystemHandle | null;
|
fileHandle?: FileSystemHandle | null;
|
||||||
|
exportingFrame: ExcalidrawFrameElement | null;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
@@ -49,6 +111,7 @@ export const exportCanvas = async (
|
|||||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||||
},
|
},
|
||||||
files,
|
files,
|
||||||
|
{ exportingFrame },
|
||||||
);
|
);
|
||||||
if (type === "svg") {
|
if (type === "svg") {
|
||||||
return await fileSave(
|
return await fileSave(
|
||||||
@@ -70,6 +133,7 @@ export const exportCanvas = async (
|
|||||||
exportBackground,
|
exportBackground,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
|
exportingFrame,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (type === "png") {
|
if (type === "png") {
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,7 @@ import {
|
|||||||
LIBRARY_SIDEBAR_TAB,
|
LIBRARY_SIDEBAR_TAB,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
||||||
|
import { cloneJSON } from "../utils";
|
||||||
|
|
||||||
export const libraryItemsAtom = atom<{
|
export const libraryItemsAtom = atom<{
|
||||||
status: "loading" | "loaded";
|
status: "loading" | "loaded";
|
||||||
@@ -31,7 +32,7 @@ export const libraryItemsAtom = atom<{
|
|||||||
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
||||||
|
|
||||||
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||||
JSON.parse(JSON.stringify(libraryItems));
|
cloneJSON(libraryItems);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* checks if library item does not exist already in current library
|
* checks if library item does not exist already in current library
|
||||||
|
|||||||
+12
-12
@@ -1,7 +1,6 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { exportCanvas } from ".";
|
import { exportCanvas, prepareElementsForExport } from ".";
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||||
|
|
||||||
export const resaveAsImageWithScene = async (
|
export const resaveAsImageWithScene = async (
|
||||||
@@ -23,18 +22,19 @@ export const resaveAsImageWithScene = async (
|
|||||||
exportEmbedScene: true,
|
exportEmbedScene: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await exportCanvas(
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
fileHandleType,
|
elements,
|
||||||
getNonDeletedElements(elements),
|
|
||||||
appState,
|
appState,
|
||||||
files,
|
false,
|
||||||
{
|
|
||||||
exportBackground,
|
|
||||||
viewBackgroundColor,
|
|
||||||
name,
|
|
||||||
fileHandle,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await exportCanvas(fileHandleType, exportedElements, appState, files, {
|
||||||
|
exportBackground,
|
||||||
|
viewBackgroundColor,
|
||||||
|
name,
|
||||||
|
fileHandle,
|
||||||
|
exportingFrame,
|
||||||
|
});
|
||||||
|
|
||||||
return { fileHandle };
|
return { fileHandle };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { MarkOptional } from "../utility-types";
|
import { MarkOptional } from "../utility-types";
|
||||||
import { assertNever, getFontString } from "../utils";
|
import { assertNever, cloneJSON, getFontString } from "../utils";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
|
|
||||||
@@ -368,7 +368,8 @@ const bindLinearElementToElement = (
|
|||||||
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
|
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
|
||||||
const endPointIndex = linearElement.points.length - 1;
|
const endPointIndex = linearElement.points.length - 1;
|
||||||
const delta = 0.5;
|
const delta = 0.5;
|
||||||
const newPoints = JSON.parse(JSON.stringify(linearElement.points));
|
|
||||||
|
const newPoints = cloneJSON(linearElement.points) as [number, number][];
|
||||||
// left to right so shift the arrow towards right
|
// left to right so shift the arrow towards right
|
||||||
if (
|
if (
|
||||||
linearElement.points[endPointIndex][0] >
|
linearElement.points[endPointIndex][0] >
|
||||||
@@ -439,9 +440,7 @@ export const convertToExcalidrawElements = (
|
|||||||
if (!elementsSkeleton) {
|
if (!elementsSkeleton) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const elements: ExcalidrawElementSkeleton[] = JSON.parse(
|
const elements = cloneJSON(elementsSkeleton);
|
||||||
JSON.stringify(elementsSkeleton),
|
|
||||||
);
|
|
||||||
const elementStore = new ElementStore();
|
const elementStore = new ElementStore();
|
||||||
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
||||||
const oldToNewElementIdMap = new Map<string, string>();
|
const oldToNewElementIdMap = new Map<string, string>();
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const ALLOWED_DOMAINS = new Set([
|
|||||||
"stackblitz.com",
|
"stackblitz.com",
|
||||||
"val.town",
|
"val.town",
|
||||||
"giphy.com",
|
"giphy.com",
|
||||||
|
"dddice.com",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const createSrcDoc = (body: string) => {
|
const createSrcDoc = (body: string) => {
|
||||||
@@ -200,7 +201,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
|||||||
return { link, aspectRatio, type };
|
return { link, aspectRatio, type };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isEmbeddableOrFrameLabel = (
|
export const isEmbeddableOrLabel = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
): Boolean => {
|
): Boolean => {
|
||||||
if (isEmbeddableElement(element)) {
|
if (isEmbeddableElement(element)) {
|
||||||
|
|||||||
@@ -249,8 +249,10 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
h.app.refreshViewportBreakpoints();
|
||||||
|
// @ts-ignore
|
||||||
|
h.app.refreshEditorBreakpoints();
|
||||||
|
|
||||||
textElement = UI.createElement("text");
|
textElement = UI.createElement("text");
|
||||||
|
|
||||||
|
|||||||
+27
-11
@@ -1,6 +1,7 @@
|
|||||||
import { ROUNDNESS } from "../constants";
|
import { ROUNDNESS } from "../constants";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { MarkNonNullable } from "../utility-types";
|
import { MarkNonNullable } from "../utility-types";
|
||||||
|
import { assertNever } from "../utils";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -140,17 +141,32 @@ export const isTextBindableContainer = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isExcalidrawElement = (element: any): boolean => {
|
export const isExcalidrawElement = (
|
||||||
return (
|
element: any,
|
||||||
element?.type === "text" ||
|
): element is ExcalidrawElement => {
|
||||||
element?.type === "diamond" ||
|
const type: ExcalidrawElement["type"] | undefined = element?.type;
|
||||||
element?.type === "rectangle" ||
|
if (!type) {
|
||||||
element?.type === "embeddable" ||
|
return false;
|
||||||
element?.type === "ellipse" ||
|
}
|
||||||
element?.type === "arrow" ||
|
switch (type) {
|
||||||
element?.type === "freedraw" ||
|
case "text":
|
||||||
element?.type === "line"
|
case "diamond":
|
||||||
);
|
case "rectangle":
|
||||||
|
case "embeddable":
|
||||||
|
case "ellipse":
|
||||||
|
case "arrow":
|
||||||
|
case "freedraw":
|
||||||
|
case "line":
|
||||||
|
case "frame":
|
||||||
|
case "image":
|
||||||
|
case "selection": {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
assertNever(type, null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hasBoundTextElement = (
|
export const hasBoundTextElement = (
|
||||||
|
|||||||
+108
-62
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { getCommonBounds, getElementBounds, isTextElement } from "./element";
|
||||||
getCommonBounds,
|
|
||||||
getElementAbsoluteCoords,
|
|
||||||
isTextElement,
|
|
||||||
} from "./element";
|
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawFrameElement,
|
ExcalidrawFrameElement,
|
||||||
@@ -56,6 +52,7 @@ export const bindElementsToFramesAfterDuplication = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --------------------------- Frame Geometry ---------------------------------
|
||||||
export function isElementIntersectingFrame(
|
export function isElementIntersectingFrame(
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
frame: ExcalidrawFrameElement,
|
frame: ExcalidrawFrameElement,
|
||||||
@@ -85,36 +82,27 @@ export const getElementsCompletelyInFrame = (
|
|||||||
element.frameId === frame.id,
|
element.frameId === frame.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const isElementContainingFrame = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
frame: ExcalidrawFrameElement,
|
|
||||||
) => {
|
|
||||||
return getElementsWithinSelection(elements, element).some(
|
|
||||||
(e) => e.id === frame.id,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getElementsIntersectingFrame = (
|
export const getElementsIntersectingFrame = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
frame: ExcalidrawFrameElement,
|
frame: ExcalidrawFrameElement,
|
||||||
) => elements.filter((element) => isElementIntersectingFrame(element, frame));
|
) => elements.filter((element) => isElementIntersectingFrame(element, frame));
|
||||||
|
|
||||||
export const elementsAreInFrameBounds = (
|
export const elementsAreInBounds = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
frame: ExcalidrawFrameElement,
|
element: ExcalidrawElement,
|
||||||
|
tolerance = 0,
|
||||||
) => {
|
) => {
|
||||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
|
||||||
getElementAbsoluteCoords(frame);
|
|
||||||
|
|
||||||
const [elementX1, elementY1, elementX2, elementY2] =
|
const [elementX1, elementY1, elementX2, elementY2] =
|
||||||
|
getElementBounds(element);
|
||||||
|
|
||||||
|
const [elementsX1, elementsY1, elementsX2, elementsY2] =
|
||||||
getCommonBounds(elements);
|
getCommonBounds(elements);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
selectionX1 <= elementX1 &&
|
elementX1 <= elementsX1 - tolerance &&
|
||||||
selectionY1 <= elementY1 &&
|
elementY1 <= elementsY1 - tolerance &&
|
||||||
selectionX2 >= elementX2 &&
|
elementX2 >= elementsX2 + tolerance &&
|
||||||
selectionY2 >= elementY2
|
elementY2 >= elementsY2 + tolerance
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,9 +111,12 @@ export const elementOverlapsWithFrame = (
|
|||||||
frame: ExcalidrawFrameElement,
|
frame: ExcalidrawFrameElement,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
elementsAreInFrameBounds([element], frame) ||
|
// frame contains element
|
||||||
isElementIntersectingFrame(element, frame) ||
|
elementsAreInBounds([element], frame) ||
|
||||||
isElementContainingFrame([frame], element, frame)
|
// element contains frame
|
||||||
|
(elementsAreInBounds([frame], element) && element.frameId === frame.id) ||
|
||||||
|
// element intersects with frame
|
||||||
|
isElementIntersectingFrame(element, frame)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,7 +127,7 @@ export const isCursorInFrame = (
|
|||||||
},
|
},
|
||||||
frame: NonDeleted<ExcalidrawFrameElement>,
|
frame: NonDeleted<ExcalidrawFrameElement>,
|
||||||
) => {
|
) => {
|
||||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
|
const [fx1, fy1, fx2, fy2] = getElementBounds(frame);
|
||||||
|
|
||||||
return isPointWithinBounds(
|
return isPointWithinBounds(
|
||||||
[fx1, fy1],
|
[fx1, fy1],
|
||||||
@@ -160,7 +151,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
|
|||||||
|
|
||||||
return !!elementsInGroup.find(
|
return !!elementsInGroup.find(
|
||||||
(element) =>
|
(element) =>
|
||||||
elementsAreInFrameBounds([element], frame) ||
|
elementsAreInBounds([element], frame) ||
|
||||||
isElementIntersectingFrame(element, frame),
|
isElementIntersectingFrame(element, frame),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -181,7 +172,7 @@ export const groupsAreCompletelyOutOfFrame = (
|
|||||||
return (
|
return (
|
||||||
elementsInGroup.find(
|
elementsInGroup.find(
|
||||||
(element) =>
|
(element) =>
|
||||||
elementsAreInFrameBounds([element], frame) ||
|
elementsAreInBounds([element], frame) ||
|
||||||
isElementIntersectingFrame(element, frame),
|
isElementIntersectingFrame(element, frame),
|
||||||
) === undefined
|
) === undefined
|
||||||
);
|
);
|
||||||
@@ -201,32 +192,66 @@ export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
|
|||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
const frameId = isFrameElement(element) ? element.id : element.frameId;
|
const frameId = isFrameElement(element) ? element.id : element.frameId;
|
||||||
if (frameId && !frameElementsMap.has(frameId)) {
|
if (frameId && !frameElementsMap.has(frameId)) {
|
||||||
frameElementsMap.set(frameId, getFrameElements(elements, frameId));
|
frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return frameElementsMap;
|
return frameElementsMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFrameElements = (
|
export const getFrameChildren = (
|
||||||
allElements: ExcalidrawElementsIncludingDeleted,
|
allElements: ExcalidrawElementsIncludingDeleted,
|
||||||
frameId: string,
|
frameId: string,
|
||||||
) => allElements.filter((element) => element.frameId === frameId);
|
) => allElements.filter((element) => element.frameId === frameId);
|
||||||
|
|
||||||
|
export const getFrameElements = (
|
||||||
|
allElements: ExcalidrawElementsIncludingDeleted,
|
||||||
|
): ExcalidrawFrameElement[] => {
|
||||||
|
return allElements.filter((element) =>
|
||||||
|
isFrameElement(element),
|
||||||
|
) as ExcalidrawFrameElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns ExcalidrawFrameElements and non-frame-children elements.
|
||||||
|
*
|
||||||
|
* Considers children as root elements if they point to a frame parent
|
||||||
|
* non-existing in the elements set.
|
||||||
|
*
|
||||||
|
* Considers non-frame bound elements (container or arrow labels) as root.
|
||||||
|
*/
|
||||||
|
export const getRootElements = (
|
||||||
|
allElements: ExcalidrawElementsIncludingDeleted,
|
||||||
|
) => {
|
||||||
|
const frameElements = arrayToMap(getFrameElements(allElements));
|
||||||
|
return allElements.filter(
|
||||||
|
(element) =>
|
||||||
|
frameElements.has(element.id) ||
|
||||||
|
!element.frameId ||
|
||||||
|
!frameElements.has(element.frameId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getElementsInResizingFrame = (
|
export const getElementsInResizingFrame = (
|
||||||
allElements: ExcalidrawElementsIncludingDeleted,
|
allElements: ExcalidrawElementsIncludingDeleted,
|
||||||
frame: ExcalidrawFrameElement,
|
frame: ExcalidrawFrameElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const prevElementsInFrame = getFrameElements(allElements, frame.id);
|
const prevElementsInFrame = getFrameChildren(allElements, frame.id);
|
||||||
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
|
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
|
||||||
|
|
||||||
const elementsCompletelyInFrame = new Set([
|
const elementsCompletelyInFrame = new Set<ExcalidrawElement>(
|
||||||
...getElementsCompletelyInFrame(allElements, frame),
|
getElementsCompletelyInFrame(allElements, frame),
|
||||||
...prevElementsInFrame.filter((element) =>
|
);
|
||||||
isElementContainingFrame(allElements, element, frame),
|
|
||||||
),
|
for (const element of prevElementsInFrame) {
|
||||||
]);
|
if (!elementsCompletelyInFrame.has(element)) {
|
||||||
|
// element contains the frame
|
||||||
|
if (elementsAreInBounds([frame], element)) {
|
||||||
|
elementsCompletelyInFrame.add(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
|
const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
|
||||||
(element) => !elementsCompletelyInFrame.has(element),
|
(element) => !elementsCompletelyInFrame.has(element),
|
||||||
@@ -293,7 +318,7 @@ export const getElementsInResizingFrame = (
|
|||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
const elementsInGroup = getElementsInGroup(allElements, id);
|
const elementsInGroup = getElementsInGroup(allElements, id);
|
||||||
|
|
||||||
if (elementsAreInFrameBounds(elementsInGroup, frame)) {
|
if (elementsAreInBounds(elementsInGroup, frame)) {
|
||||||
for (const element of elementsInGroup) {
|
for (const element of elementsInGroup) {
|
||||||
nextElementsInFrame.add(element);
|
nextElementsInFrame.add(element);
|
||||||
}
|
}
|
||||||
@@ -342,7 +367,7 @@ export const getContainingFrame = (
|
|||||||
// --------------------------- Frame Operations -------------------------------
|
// --------------------------- Frame Operations -------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retains (or repairs for target frame) the ordering invriant where children
|
* Retains (or repairs for target frame) the ordering invariant where children
|
||||||
* elements come right before the parent frame:
|
* elements come right before the parent frame:
|
||||||
* [el, el, child, child, frame, el]
|
* [el, el, child, child, frame, el]
|
||||||
*/
|
*/
|
||||||
@@ -409,25 +434,14 @@ export const removeElementsFromFrame = (
|
|||||||
ExcalidrawElement
|
ExcalidrawElement
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const toRemoveElementsByFrame = new Map<
|
|
||||||
ExcalidrawFrameElement["id"],
|
|
||||||
ExcalidrawElement[]
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const element of elementsToRemove) {
|
for (const element of elementsToRemove) {
|
||||||
if (element.frameId) {
|
if (element.frameId) {
|
||||||
_elementsToRemove.set(element.id, element);
|
_elementsToRemove.set(element.id, element);
|
||||||
|
|
||||||
const arr = toRemoveElementsByFrame.get(element.frameId) || [];
|
|
||||||
arr.push(element);
|
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
_elementsToRemove.set(boundTextElement.id, boundTextElement);
|
_elementsToRemove.set(boundTextElement.id, boundTextElement);
|
||||||
arr.push(boundTextElement);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toRemoveElementsByFrame.set(element.frameId, arr);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +463,7 @@ export const removeAllElementsFromFrame = (
|
|||||||
frame: ExcalidrawFrameElement,
|
frame: ExcalidrawFrameElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
const elementsInFrame = getFrameElements(allElements, frame.id);
|
const elementsInFrame = getFrameChildren(allElements, frame.id);
|
||||||
return removeElementsFromFrame(allElements, elementsInFrame, appState);
|
return removeElementsFromFrame(allElements, elementsInFrame, appState);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -492,12 +506,15 @@ export const updateFrameMembershipOfSelectedElements = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elementsToRemove = new Set<ExcalidrawElement>();
|
const elementsToRemove = new Set<ExcalidrawElement>();
|
||||||
|
const processedGroupIds = new Map<string, boolean>();
|
||||||
|
|
||||||
elementsToFilter.forEach((element) => {
|
elementsToFilter.forEach((element) => {
|
||||||
if (
|
if (
|
||||||
element.frameId &&
|
element.frameId &&
|
||||||
!isFrameElement(element) &&
|
!isFrameElement(element) &&
|
||||||
!isElementInFrame(element, allElements, appState)
|
!isElementInFrame(element, allElements, appState, {
|
||||||
|
processedGroupIds,
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
elementsToRemove.add(element);
|
elementsToRemove.add(element);
|
||||||
}
|
}
|
||||||
@@ -559,26 +576,36 @@ export const getTargetFrame = (
|
|||||||
: getContainingFrame(_element);
|
: getContainingFrame(_element);
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: this a huge bottleneck for large scenes, optimise
|
|
||||||
// given an element, return if the element is in some frame
|
// given an element, return if the element is in some frame
|
||||||
export const isElementInFrame = (
|
export const isElementInFrame = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
allElements: ExcalidrawElementsIncludingDeleted,
|
allElements: ExcalidrawElementsIncludingDeleted,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState,
|
||||||
|
opts?: {
|
||||||
|
targetFrame?: ExcalidrawFrameElement;
|
||||||
|
processedGroupIds?: Map<string, boolean>;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const frame = getTargetFrame(element, appState);
|
const frame = opts?.targetFrame ?? getTargetFrame(element, appState);
|
||||||
const _element = isTextElement(element)
|
const _element = isTextElement(element)
|
||||||
? getContainerElement(element) || element
|
? getContainerElement(element) || element
|
||||||
: element;
|
: element;
|
||||||
|
|
||||||
|
const groupsInFrame = (yes: boolean) => {
|
||||||
|
if (opts?.processedGroupIds) {
|
||||||
|
_element.groupIds.forEach((gid) => {
|
||||||
|
opts.processedGroupIds?.set(gid, yes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (frame) {
|
if (frame) {
|
||||||
// Perf improvement:
|
// Perf improvement:
|
||||||
// For an element that's already in a frame, if it's not being dragged
|
// For an element that's already in a frame, if it's not being selected
|
||||||
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
|
// and its frame is not being selected, it has to be in its containing frame.
|
||||||
// It has to be in its containing frame.
|
|
||||||
if (
|
if (
|
||||||
!appState.selectedElementIds[element.id] ||
|
!appState.selectedElementIds[element.id] &&
|
||||||
!appState.selectedElementsAreBeingDragged
|
!appState.selectedElementIds[frame.id]
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -587,8 +614,21 @@ export const isElementInFrame = (
|
|||||||
return elementOverlapsWithFrame(_element, frame);
|
return elementOverlapsWithFrame(_element, frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const gid of _element.groupIds) {
|
||||||
|
if (opts?.processedGroupIds?.has(gid)) {
|
||||||
|
return opts.processedGroupIds.get(gid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allElementsInGroup = new Set(
|
const allElementsInGroup = new Set(
|
||||||
_element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)),
|
_element.groupIds
|
||||||
|
.filter((gid) => {
|
||||||
|
if (opts?.processedGroupIds) {
|
||||||
|
return !opts.processedGroupIds.has(gid);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.flatMap((gid) => getElementsInGroup(allElements, gid)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||||
@@ -609,16 +649,22 @@ export const isElementInFrame = (
|
|||||||
|
|
||||||
for (const elementInGroup of allElementsInGroup) {
|
for (const elementInGroup of allElementsInGroup) {
|
||||||
if (isFrameElement(elementInGroup)) {
|
if (isFrameElement(elementInGroup)) {
|
||||||
|
groupsInFrame(false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const elementInGroup of allElementsInGroup) {
|
for (const elementInGroup of allElementsInGroup) {
|
||||||
if (elementOverlapsWithFrame(elementInGroup, frame)) {
|
if (elementOverlapsWithFrame(elementInGroup, frame)) {
|
||||||
|
groupsInFrame(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_element.groupIds.length > 0) {
|
||||||
|
groupsInFrame(false);
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-4
@@ -232,6 +232,8 @@ export const selectGroupsFromGivenElements = (
|
|||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const processedGroupIds = new Set<string>();
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
let groupIds = element.groupIds;
|
let groupIds = element.groupIds;
|
||||||
if (appState.editingGroupId) {
|
if (appState.editingGroupId) {
|
||||||
@@ -242,10 +244,13 @@ export const selectGroupsFromGivenElements = (
|
|||||||
}
|
}
|
||||||
if (groupIds.length > 0) {
|
if (groupIds.length > 0) {
|
||||||
const groupId = groupIds[groupIds.length - 1];
|
const groupId = groupIds[groupIds.length - 1];
|
||||||
nextAppState = {
|
if (!processedGroupIds.has(groupId)) {
|
||||||
...nextAppState,
|
nextAppState = {
|
||||||
...selectGroup(groupId, nextAppState, elements),
|
...nextAppState,
|
||||||
};
|
...selectGroup(groupId, nextAppState, elements),
|
||||||
|
};
|
||||||
|
processedGroupIds.add(groupId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useLayoutEffect } from "react";
|
import { useState, useLayoutEffect } from "react";
|
||||||
import { useDevice, useExcalidrawContainer } from "../components/App";
|
import { useDevice, useExcalidrawContainer } from "../components/App";
|
||||||
import { useUIAppState } from "../context/ui-appState";
|
import { useUIAppState } from "../context/ui-appState";
|
||||||
|
|
||||||
@@ -10,8 +10,6 @@ export const useCreatePortalContainer = (opts?: {
|
|||||||
|
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const { theme } = useUIAppState();
|
const { theme } = useUIAppState();
|
||||||
const isMobileRef = useRef(device.isMobile);
|
|
||||||
isMobileRef.current = device.isMobile;
|
|
||||||
|
|
||||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||||
|
|
||||||
@@ -19,11 +17,10 @@ export const useCreatePortalContainer = (opts?: {
|
|||||||
if (div) {
|
if (div) {
|
||||||
div.className = "";
|
div.className = "";
|
||||||
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
|
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
|
||||||
div.classList.toggle("excalidraw--mobile", device.isMobile);
|
div.classList.toggle("excalidraw--mobile", device.editor.isMobile);
|
||||||
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
|
|
||||||
div.classList.toggle("theme--dark", theme === "dark");
|
div.classList.toggle("theme--dark", theme === "dark");
|
||||||
}
|
}
|
||||||
}, [div, theme, device.isMobile, opts?.className]);
|
}, [div, theme, device.editor.isMobile, opts?.className]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = opts?.parentSelector
|
const container = opts?.parentSelector
|
||||||
|
|||||||
@@ -15,10 +15,28 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- Support `excalidrawAPI` prop for accessing the Excalidraw API [#7251](https://github.com/excalidraw/excalidraw/pull/7251).
|
||||||
|
|
||||||
|
#### BREAKING CHANGE
|
||||||
|
|
||||||
|
- The `Ref` support has been removed in v0.17.0 so if you are using refs, please update the integration to use the [`excalidrawAPI`](http://localhost:3003/docs/@excalidraw/excalidraw/api/props/excalidraw-api)
|
||||||
|
|
||||||
|
- Additionally `ready` and `readyPromise` from the API have been discontinued. These APIs were found to be superfluous, and as part of the effort to streamline the APIs and maintain simplicity, they were removed in version v0.17.0.
|
||||||
|
|
||||||
|
- Export [`getCommonBounds`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#getcommonbounds) helper from the package [#7247](https://github.com/excalidraw/excalidraw/pull/7247).
|
||||||
|
|
||||||
|
- Support frames via programmatic API [#7205](https://github.com/excalidraw/excalidraw/pull/7205).
|
||||||
|
|
||||||
- Export `elementsOverlappingBBox`, `isElementInsideBBox`, `elementPartiallyOverlapsWithOrContainsBBox` helpers for filtering/checking if elements within bounds. [#6727](https://github.com/excalidraw/excalidraw/pull/6727)
|
- Export `elementsOverlappingBBox`, `isElementInsideBBox`, `elementPartiallyOverlapsWithOrContainsBBox` helpers for filtering/checking if elements within bounds. [#6727](https://github.com/excalidraw/excalidraw/pull/6727)
|
||||||
|
|
||||||
- Regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping [#7195](https://github.com/excalidraw/excalidraw/pull/7195)
|
- Regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping [#7195](https://github.com/excalidraw/excalidraw/pull/7195)
|
||||||
|
|
||||||
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [#7078](https://github.com/excalidraw/excalidraw/pull/7078)
|
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [#7078](https://github.com/excalidraw/excalidraw/pull/7078)
|
||||||
|
|
||||||
|
#### BREAKING CHANGES
|
||||||
|
|
||||||
|
- [`useDevice`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#usedevice) hook's return value was changed to differentiate between `editor` and `viewport` breakpoints. [#7243](https://github.com/excalidraw/excalidraw/pull/7243)
|
||||||
|
|
||||||
## 0.16.1 (2023-09-21)
|
## 0.16.1 (2023-09-21)
|
||||||
|
|
||||||
## Excalidraw Library
|
## Excalidraw Library
|
||||||
|
|||||||
@@ -665,7 +665,9 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="excalidraw-wrapper">
|
<div className="excalidraw-wrapper">
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api: ExcalidrawImperativeAPI) =>
|
||||||
|
setExcalidrawAPI(api)
|
||||||
|
}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
onChange={(elements, state) => {
|
onChange={(elements, state) => {
|
||||||
console.info("Elements :", elements, "State : ", state);
|
console.info("Elements :", elements, "State : ", state);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const MobileFooter = ({
|
|||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
if (device.isMobile) {
|
if (device.editor.isMobile) {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<CustomFooter excalidrawAPI={excalidrawAPI} />
|
<CustomFooter excalidrawAPI={excalidrawAPI} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, forwardRef } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { InitializeApp } from "../../components/InitializeApp";
|
import { InitializeApp } from "../../components/InitializeApp";
|
||||||
import App from "../../components/App";
|
import App from "../../components/App";
|
||||||
import { isShallowEqual } from "../../utils";
|
import { isShallowEqual } from "../../utils";
|
||||||
@@ -6,7 +6,7 @@ import { isShallowEqual } from "../../utils";
|
|||||||
import "../../css/app.scss";
|
import "../../css/app.scss";
|
||||||
import "../../css/styles.scss";
|
import "../../css/styles.scss";
|
||||||
|
|
||||||
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
|
import { AppProps, ExcalidrawProps } from "../../types";
|
||||||
import { defaultLang } from "../../i18n";
|
import { defaultLang } from "../../i18n";
|
||||||
import { DEFAULT_UI_OPTIONS } from "../../constants";
|
import { DEFAULT_UI_OPTIONS } from "../../constants";
|
||||||
import { Provider } from "jotai";
|
import { Provider } from "jotai";
|
||||||
@@ -20,7 +20,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
const {
|
const {
|
||||||
onChange,
|
onChange,
|
||||||
initialData,
|
initialData,
|
||||||
excalidrawRef,
|
excalidrawAPI,
|
||||||
isCollaborating = false,
|
isCollaborating = false,
|
||||||
onPointerUpdate,
|
onPointerUpdate,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
@@ -95,7 +95,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
<App
|
<App
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
excalidrawRef={excalidrawRef}
|
excalidrawAPI={excalidrawAPI}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onPointerUpdate={onPointerUpdate}
|
onPointerUpdate={onPointerUpdate}
|
||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
@@ -127,12 +127,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type PublicExcalidrawProps = Omit<ExcalidrawProps, "forwardedRef">;
|
const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
|
||||||
|
|
||||||
const areEqual = (
|
|
||||||
prevProps: PublicExcalidrawProps,
|
|
||||||
nextProps: PublicExcalidrawProps,
|
|
||||||
) => {
|
|
||||||
// short-circuit early
|
// short-circuit early
|
||||||
if (prevProps.children !== nextProps.children) {
|
if (prevProps.children !== nextProps.children) {
|
||||||
return false;
|
return false;
|
||||||
@@ -189,12 +184,7 @@ const areEqual = (
|
|||||||
return isUIOptionsSame && isShallowEqual(prev, next);
|
return isUIOptionsSame && isShallowEqual(prev, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const forwardedRefComp = forwardRef<
|
export const Excalidraw = React.memo(ExcalidrawBase, areEqual);
|
||||||
ExcalidrawAPIRefValue,
|
|
||||||
PublicExcalidrawProps
|
|
||||||
>((props, ref) => <ExcalidrawBase {...props} excalidrawRef={ref} />);
|
|
||||||
|
|
||||||
export const Excalidraw = React.memo(forwardedRefComp, areEqual);
|
|
||||||
Excalidraw.displayName = "Excalidraw";
|
Excalidraw.displayName = "Excalidraw";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -254,6 +244,7 @@ export { DefaultSidebar } from "../../components/DefaultSidebar";
|
|||||||
|
|
||||||
export { normalizeLink } from "../../data/url";
|
export { normalizeLink } from "../../data/url";
|
||||||
export { convertToExcalidrawElements } from "../../data/transform";
|
export { convertToExcalidrawElements } from "../../data/transform";
|
||||||
|
export { getCommonBounds } from "../../element/bounds";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
elementsOverlappingBBox,
|
elementsOverlappingBBox,
|
||||||
|
|||||||
+16
-39
@@ -4,7 +4,11 @@ import {
|
|||||||
} from "../scene/export";
|
} from "../scene/export";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
|
NonDeleted,
|
||||||
|
} from "../element/types";
|
||||||
import { restore } from "../data/restore";
|
import { restore } from "../data/restore";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { encodePngMetadata } from "../data/image";
|
import { encodePngMetadata } from "../data/image";
|
||||||
@@ -14,24 +18,6 @@ import {
|
|||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import Scene from "../scene/Scene";
|
|
||||||
import { duplicateElements } from "../element/newElement";
|
|
||||||
|
|
||||||
// getContainerElement and getBoundTextElement and potentially other helpers
|
|
||||||
// depend on `Scene` which will not be available when these pure utils are
|
|
||||||
// called outside initialized Excalidraw editor instance or even if called
|
|
||||||
// from inside Excalidraw if the elements were never cached by Scene (e.g.
|
|
||||||
// for library elements).
|
|
||||||
//
|
|
||||||
// As such, before passing the elements down, we need to initialize a custom
|
|
||||||
// Scene instance and assign them to it.
|
|
||||||
//
|
|
||||||
// FIXME This is a super hacky workaround and we'll need to rewrite this soon.
|
|
||||||
const passElementsSafely = (elements: readonly ExcalidrawElement[]) => {
|
|
||||||
const scene = new Scene();
|
|
||||||
scene.replaceAllElements(duplicateElements(elements));
|
|
||||||
return scene.getNonDeletedElements();
|
|
||||||
};
|
|
||||||
|
|
||||||
export { MIME_TYPES };
|
export { MIME_TYPES };
|
||||||
|
|
||||||
@@ -40,6 +26,7 @@ type ExportOpts = {
|
|||||||
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
||||||
files: BinaryFiles | null;
|
files: BinaryFiles | null;
|
||||||
maxWidthOrHeight?: number;
|
maxWidthOrHeight?: number;
|
||||||
|
exportingFrame?: ExcalidrawFrameElement | null;
|
||||||
getDimensions?: (
|
getDimensions?: (
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
@@ -53,6 +40,7 @@ export const exportToCanvas = ({
|
|||||||
maxWidthOrHeight,
|
maxWidthOrHeight,
|
||||||
getDimensions,
|
getDimensions,
|
||||||
exportPadding,
|
exportPadding,
|
||||||
|
exportingFrame,
|
||||||
}: ExportOpts & {
|
}: ExportOpts & {
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -63,10 +51,10 @@ export const exportToCanvas = ({
|
|||||||
);
|
);
|
||||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
||||||
return _exportToCanvas(
|
return _exportToCanvas(
|
||||||
passElementsSafely(restoredElements),
|
restoredElements,
|
||||||
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
|
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
|
||||||
files || {},
|
files || {},
|
||||||
{ exportBackground, exportPadding, viewBackgroundColor },
|
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
|
||||||
(width: number, height: number) => {
|
(width: number, height: number) => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
|
|
||||||
@@ -135,10 +123,8 @@ export const exportToBlob = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = await exportToCanvas({
|
const canvas = await exportToCanvas(opts);
|
||||||
...opts,
|
|
||||||
elements: passElementsSafely(opts.elements),
|
|
||||||
});
|
|
||||||
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
|
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -179,6 +165,7 @@ export const exportToSvg = async ({
|
|||||||
files = {},
|
files = {},
|
||||||
exportPadding,
|
exportPadding,
|
||||||
renderEmbeddables,
|
renderEmbeddables,
|
||||||
|
exportingFrame,
|
||||||
}: Omit<ExportOpts, "getDimensions"> & {
|
}: Omit<ExportOpts, "getDimensions"> & {
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
renderEmbeddables?: boolean;
|
renderEmbeddables?: boolean;
|
||||||
@@ -194,20 +181,10 @@ export const exportToSvg = async ({
|
|||||||
exportPadding,
|
exportPadding,
|
||||||
};
|
};
|
||||||
|
|
||||||
return _exportToSvg(
|
return _exportToSvg(restoredElements, exportAppState, files, {
|
||||||
passElementsSafely(restoredElements),
|
exportingFrame,
|
||||||
exportAppState,
|
renderEmbeddables,
|
||||||
files,
|
});
|
||||||
{
|
|
||||||
renderEmbeddables,
|
|
||||||
// NOTE as long as we're using the Scene hack, we need to ensure
|
|
||||||
// we pass the original, uncloned elements when serializing
|
|
||||||
// so that we keep ids stable. Hence adding the serializeAsJSON helper
|
|
||||||
// support into the downstream exportToSvg function.
|
|
||||||
serializeAsJSON: () =>
|
|
||||||
serializeAsJSON(restoredElements, exportAppState, files || {}, "local"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exportToClipboard = async (
|
export const exportToClipboard = async (
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import type {
|
|||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
|
isExcalidrawElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { isValueInRange, rotatePoint } from "../math";
|
import { isValueInRange, rotatePoint } from "../math";
|
||||||
import type { Point } from "../types";
|
import type { Point } from "../types";
|
||||||
import { Bounds } from "../element/bounds";
|
import { Bounds, getElementBounds } from "../element/bounds";
|
||||||
|
|
||||||
type Element = NonDeletedExcalidrawElement;
|
type Element = NonDeletedExcalidrawElement;
|
||||||
type Elements = readonly NonDeletedExcalidrawElement[];
|
type Elements = readonly NonDeletedExcalidrawElement[];
|
||||||
@@ -146,7 +147,7 @@ export const elementsOverlappingBBox = ({
|
|||||||
errorMargin = 0,
|
errorMargin = 0,
|
||||||
}: {
|
}: {
|
||||||
elements: Elements;
|
elements: Elements;
|
||||||
bounds: Bounds;
|
bounds: Bounds | ExcalidrawElement;
|
||||||
/** safety offset. Defaults to 0. */
|
/** safety offset. Defaults to 0. */
|
||||||
errorMargin?: number;
|
errorMargin?: number;
|
||||||
/**
|
/**
|
||||||
@@ -156,6 +157,9 @@ export const elementsOverlappingBBox = ({
|
|||||||
**/
|
**/
|
||||||
type: "overlap" | "contain" | "inside";
|
type: "overlap" | "contain" | "inside";
|
||||||
}) => {
|
}) => {
|
||||||
|
if (isExcalidrawElement(bounds)) {
|
||||||
|
bounds = getElementBounds(bounds);
|
||||||
|
}
|
||||||
const adjustedBBox: Bounds = [
|
const adjustedBBox: Bounds = [
|
||||||
bounds[0] - errorMargin,
|
bounds[0] - errorMargin,
|
||||||
bounds[1] - errorMargin,
|
bounds[1] - errorMargin,
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import type { Drawable } from "roughjs/bin/core";
|
|||||||
import type { RoughSVG } from "roughjs/bin/svg";
|
import type { RoughSVG } from "roughjs/bin/svg";
|
||||||
|
|
||||||
import { StaticCanvasRenderConfig } from "../scene/types";
|
import { StaticCanvasRenderConfig } from "../scene/types";
|
||||||
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
import {
|
||||||
|
distance,
|
||||||
|
getFontString,
|
||||||
|
getFontFamilyString,
|
||||||
|
isRTL,
|
||||||
|
isTestEnv,
|
||||||
|
} from "../utils";
|
||||||
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import {
|
import {
|
||||||
@@ -589,11 +595,7 @@ export const renderElement = (
|
|||||||
) => {
|
) => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "frame": {
|
case "frame": {
|
||||||
if (
|
if (appState.frameRendering.enabled && appState.frameRendering.outline) {
|
||||||
!renderConfig.isExporting &&
|
|
||||||
appState.frameRendering.enabled &&
|
|
||||||
appState.frameRendering.outline
|
|
||||||
) {
|
|
||||||
context.save();
|
context.save();
|
||||||
context.translate(
|
context.translate(
|
||||||
element.x + appState.scrollX,
|
element.x + appState.scrollX,
|
||||||
@@ -601,7 +603,7 @@ export const renderElement = (
|
|||||||
);
|
);
|
||||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||||
|
|
||||||
context.lineWidth = 2 / appState.zoom.value;
|
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||||
|
|
||||||
if (FRAME_STYLE.radius && context.roundRect) {
|
if (FRAME_STYLE.radius && context.roundRect) {
|
||||||
@@ -841,10 +843,13 @@ const maybeWrapNodesInFrameClipPath = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
root: SVGElement,
|
root: SVGElement,
|
||||||
nodes: SVGElement[],
|
nodes: SVGElement[],
|
||||||
exportedFrameId?: string | null,
|
frameRendering: AppState["frameRendering"],
|
||||||
) => {
|
) => {
|
||||||
|
if (!frameRendering.enabled || !frameRendering.clip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const frame = getContainingFrame(element);
|
const frame = getContainingFrame(element);
|
||||||
if (frame && frame.id === exportedFrameId) {
|
if (frame) {
|
||||||
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
|
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||||
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
|
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
|
||||||
nodes.forEach((node) => g.appendChild(node));
|
nodes.forEach((node) => g.appendChild(node));
|
||||||
@@ -861,9 +866,11 @@ export const renderElementToSvg = (
|
|||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
offsetX: number,
|
offsetX: number,
|
||||||
offsetY: number,
|
offsetY: number,
|
||||||
exportWithDarkMode?: boolean,
|
renderConfig: {
|
||||||
exportingFrameId?: string | null,
|
exportWithDarkMode: boolean;
|
||||||
renderEmbeddables?: boolean,
|
renderEmbeddables: boolean;
|
||||||
|
frameRendering: AppState["frameRendering"];
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const offset = { x: offsetX, y: offsetY };
|
const offset = { x: offsetX, y: offsetY };
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
@@ -897,6 +904,13 @@ export const renderElementToSvg = (
|
|||||||
root = anchorTag;
|
root = anchorTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
|
||||||
|
if (isTestEnv()) {
|
||||||
|
node.setAttribute("data-id", element.id);
|
||||||
|
}
|
||||||
|
root.appendChild(node);
|
||||||
|
};
|
||||||
|
|
||||||
const opacity =
|
const opacity =
|
||||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||||
|
|
||||||
@@ -931,10 +945,10 @@ export const renderElementToSvg = (
|
|||||||
element,
|
element,
|
||||||
root,
|
root,
|
||||||
[node],
|
[node],
|
||||||
exportingFrameId,
|
renderConfig.frameRendering,
|
||||||
);
|
);
|
||||||
|
|
||||||
g ? root.appendChild(g) : root.appendChild(node);
|
addToRoot(g || node, element);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "embeddable": {
|
case "embeddable": {
|
||||||
@@ -957,7 +971,7 @@ export const renderElementToSvg = (
|
|||||||
offsetY || 0
|
offsetY || 0
|
||||||
}) rotate(${degree} ${cx} ${cy})`,
|
}) rotate(${degree} ${cx} ${cy})`,
|
||||||
);
|
);
|
||||||
root.appendChild(node);
|
addToRoot(node, element);
|
||||||
|
|
||||||
const label: ExcalidrawElement =
|
const label: ExcalidrawElement =
|
||||||
createPlaceholderEmbeddableLabel(element);
|
createPlaceholderEmbeddableLabel(element);
|
||||||
@@ -968,9 +982,7 @@ export const renderElementToSvg = (
|
|||||||
files,
|
files,
|
||||||
label.x + offset.x - element.x,
|
label.x + offset.x - element.x,
|
||||||
label.y + offset.y - element.y,
|
label.y + offset.y - element.y,
|
||||||
exportWithDarkMode,
|
renderConfig,
|
||||||
exportingFrameId,
|
|
||||||
renderEmbeddables,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// render embeddable element + iframe
|
// render embeddable element + iframe
|
||||||
@@ -999,7 +1011,10 @@ export const renderElementToSvg = (
|
|||||||
// if rendering embeddables explicitly disabled or
|
// if rendering embeddables explicitly disabled or
|
||||||
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
|
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
|
||||||
// replace with a link instead
|
// replace with a link instead
|
||||||
if (renderEmbeddables === false || embedLink?.type === "document") {
|
if (
|
||||||
|
renderConfig.renderEmbeddables === false ||
|
||||||
|
embedLink?.type === "document"
|
||||||
|
) {
|
||||||
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
|
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
|
||||||
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
|
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
|
||||||
anchorTag.setAttribute("target", "_blank");
|
anchorTag.setAttribute("target", "_blank");
|
||||||
@@ -1033,8 +1048,7 @@ export const renderElementToSvg = (
|
|||||||
|
|
||||||
embeddableNode.appendChild(foreignObject);
|
embeddableNode.appendChild(foreignObject);
|
||||||
}
|
}
|
||||||
|
addToRoot(embeddableNode, element);
|
||||||
root.appendChild(embeddableNode);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "line":
|
case "line":
|
||||||
@@ -1119,12 +1133,13 @@ export const renderElementToSvg = (
|
|||||||
element,
|
element,
|
||||||
root,
|
root,
|
||||||
[group, maskPath],
|
[group, maskPath],
|
||||||
exportingFrameId,
|
renderConfig.frameRendering,
|
||||||
);
|
);
|
||||||
if (g) {
|
if (g) {
|
||||||
|
addToRoot(g, element);
|
||||||
root.appendChild(g);
|
root.appendChild(g);
|
||||||
} else {
|
} else {
|
||||||
root.appendChild(group);
|
addToRoot(group, element);
|
||||||
root.append(maskPath);
|
root.append(maskPath);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1158,10 +1173,10 @@ export const renderElementToSvg = (
|
|||||||
element,
|
element,
|
||||||
root,
|
root,
|
||||||
[node],
|
[node],
|
||||||
exportingFrameId,
|
renderConfig.frameRendering,
|
||||||
);
|
);
|
||||||
|
|
||||||
g ? root.appendChild(g) : root.appendChild(node);
|
addToRoot(g || node, element);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "image": {
|
case "image": {
|
||||||
@@ -1191,7 +1206,10 @@ export const renderElementToSvg = (
|
|||||||
use.setAttribute("href", `#${symbolId}`);
|
use.setAttribute("href", `#${symbolId}`);
|
||||||
|
|
||||||
// in dark theme, revert the image color filter
|
// in dark theme, revert the image color filter
|
||||||
if (exportWithDarkMode && fileData.mimeType !== MIME_TYPES.svg) {
|
if (
|
||||||
|
renderConfig.exportWithDarkMode &&
|
||||||
|
fileData.mimeType !== MIME_TYPES.svg
|
||||||
|
) {
|
||||||
use.setAttribute("filter", IMAGE_INVERT_FILTER);
|
use.setAttribute("filter", IMAGE_INVERT_FILTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1227,14 +1245,39 @@ export const renderElementToSvg = (
|
|||||||
element,
|
element,
|
||||||
root,
|
root,
|
||||||
[g],
|
[g],
|
||||||
exportingFrameId,
|
renderConfig.frameRendering,
|
||||||
);
|
);
|
||||||
clipG ? root.appendChild(clipG) : root.appendChild(g);
|
addToRoot(clipG || g, element);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// frames are not rendered and only acts as a container
|
// frames are not rendered and only acts as a container
|
||||||
case "frame": {
|
case "frame": {
|
||||||
|
if (
|
||||||
|
renderConfig.frameRendering.enabled &&
|
||||||
|
renderConfig.frameRendering.outline
|
||||||
|
) {
|
||||||
|
const rect = document.createElementNS(SVG_NS, "rect");
|
||||||
|
|
||||||
|
rect.setAttribute(
|
||||||
|
"transform",
|
||||||
|
`translate(${offsetX || 0} ${
|
||||||
|
offsetY || 0
|
||||||
|
}) rotate(${degree} ${cx} ${cy})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
rect.setAttribute("width", `${element.width}px`);
|
||||||
|
rect.setAttribute("height", `${element.height}px`);
|
||||||
|
// Rounded corners
|
||||||
|
rect.setAttribute("rx", FRAME_STYLE.radius.toString());
|
||||||
|
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
|
||||||
|
|
||||||
|
rect.setAttribute("fill", "none");
|
||||||
|
rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
|
||||||
|
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
|
||||||
|
|
||||||
|
addToRoot(rect, element);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@@ -1288,10 +1331,10 @@ export const renderElementToSvg = (
|
|||||||
element,
|
element,
|
||||||
root,
|
root,
|
||||||
[node],
|
[node],
|
||||||
exportingFrameId,
|
renderConfig.frameRendering,
|
||||||
);
|
);
|
||||||
|
|
||||||
g ? root.appendChild(g) : root.appendChild(node);
|
addToRoot(g || node, element);
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
throw new Error(`Unimplemented type ${element.type}`);
|
throw new Error(`Unimplemented type ${element.type}`);
|
||||||
|
|||||||
+116
-136
@@ -60,7 +60,7 @@ import {
|
|||||||
TransformHandles,
|
TransformHandles,
|
||||||
TransformHandleType,
|
TransformHandleType,
|
||||||
} from "../element/transformHandles";
|
} from "../element/transformHandles";
|
||||||
import { throttleRAF, isOnlyExportingSingleFrame } from "../utils";
|
import { throttleRAF } from "../utils";
|
||||||
import { UserIdleState } from "../types";
|
import { UserIdleState } from "../types";
|
||||||
import { FRAME_STYLE, THEME_FILTER } from "../constants";
|
import { FRAME_STYLE, THEME_FILTER } from "../constants";
|
||||||
import {
|
import {
|
||||||
@@ -71,14 +71,15 @@ import { renderSnaps } from "./renderSnaps";
|
|||||||
import {
|
import {
|
||||||
isEmbeddableElement,
|
isEmbeddableElement,
|
||||||
isFrameElement,
|
isFrameElement,
|
||||||
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
isEmbeddableOrFrameLabel,
|
isEmbeddableOrLabel,
|
||||||
createPlaceholderEmbeddableLabel,
|
createPlaceholderEmbeddableLabel,
|
||||||
} from "../element/embeddable";
|
} from "../element/embeddable";
|
||||||
import {
|
import {
|
||||||
elementOverlapsWithFrame,
|
elementsAreInBounds,
|
||||||
getTargetFrame,
|
getTargetFrame,
|
||||||
isElementInFrame,
|
isElementInFrame,
|
||||||
} from "../frame";
|
} from "../frame";
|
||||||
@@ -369,7 +370,7 @@ const frameClip = (
|
|||||||
) => {
|
) => {
|
||||||
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
|
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
if (context.roundRect && !renderConfig.isExporting) {
|
if (context.roundRect) {
|
||||||
context.roundRect(
|
context.roundRect(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
@@ -945,109 +946,79 @@ const _renderStaticScene = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupsToBeAddedToFrame = new Set<string>();
|
// Paint visible elements with embeddables on top
|
||||||
|
const visibleNonEmbeddableOrLabelElements = visibleElements.filter(
|
||||||
|
(el) => !isEmbeddableOrLabel(el),
|
||||||
|
);
|
||||||
|
|
||||||
visibleElements.forEach((element) => {
|
const visibleEmbeddableOrLabelElements = visibleElements.filter((el) =>
|
||||||
if (
|
isEmbeddableOrLabel(el),
|
||||||
element.groupIds.length > 0 &&
|
);
|
||||||
appState.frameToHighlight &&
|
|
||||||
appState.selectedElementIds[element.id] &&
|
const visibleElementsToRender = [
|
||||||
(elementOverlapsWithFrame(element, appState.frameToHighlight) ||
|
...visibleNonEmbeddableOrLabelElements,
|
||||||
element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId)))
|
...visibleEmbeddableOrLabelElements,
|
||||||
) {
|
];
|
||||||
element.groupIds.forEach((groupId) =>
|
|
||||||
groupsToBeAddedToFrame.add(groupId),
|
const _renderElement = (element: ExcalidrawElement) => {
|
||||||
);
|
try {
|
||||||
|
renderElement(element, rc, context, renderConfig, appState);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isEmbeddableElement(element) &&
|
||||||
|
(isExporting || !element.validated) &&
|
||||||
|
element.width &&
|
||||||
|
element.height
|
||||||
|
) {
|
||||||
|
const label = createPlaceholderEmbeddableLabel(element);
|
||||||
|
renderElement(label, rc, context, renderConfig, appState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExporting) {
|
||||||
|
renderLinkIcon(element, context, appState);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
// Paint visible elements
|
const processedGroupIds = new Map<string, boolean>();
|
||||||
visibleElements
|
for (const element of visibleElementsToRender) {
|
||||||
.filter((el) => !isEmbeddableOrFrameLabel(el))
|
const frameId = element.frameId || appState.frameToHighlight?.id;
|
||||||
.forEach((element) => {
|
|
||||||
try {
|
|
||||||
// - when exporting the whole canvas, we DO NOT apply clipping
|
|
||||||
// - when we are exporting a particular frame, apply clipping
|
|
||||||
// if the containing frame is not selected, apply clipping
|
|
||||||
const frameId = element.frameId || appState.frameToHighlight?.id;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
frameId &&
|
frameId &&
|
||||||
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
|
appState.frameRendering.enabled &&
|
||||||
(!renderConfig.isExporting &&
|
appState.frameRendering.clip
|
||||||
appState.frameRendering.enabled &&
|
) {
|
||||||
appState.frameRendering.clip))
|
const targetFrame = getTargetFrame(element, appState);
|
||||||
) {
|
// for perf:
|
||||||
context.save();
|
// only clip elements that are not completely in the target frame
|
||||||
|
if (
|
||||||
const frame = getTargetFrame(element, appState);
|
targetFrame &&
|
||||||
|
!elementsAreInBounds(
|
||||||
// TODO do we need to check isElementInFrame here?
|
[element],
|
||||||
if (frame && isElementInFrame(element, elements, appState)) {
|
targetFrame,
|
||||||
frameClip(frame, context, renderConfig, appState);
|
isFreeDrawElement(element)
|
||||||
}
|
? element.strokeWidth * 8
|
||||||
renderElement(element, rc, context, renderConfig, appState);
|
: element.roughness * (isLinearElement(element) ? 8 : 4),
|
||||||
context.restore();
|
) &&
|
||||||
} else {
|
isElementInFrame(element, elements, appState, {
|
||||||
renderElement(element, rc, context, renderConfig, appState);
|
targetFrame,
|
||||||
}
|
processedGroupIds,
|
||||||
if (!isExporting) {
|
})
|
||||||
renderLinkIcon(element, context, appState);
|
) {
|
||||||
}
|
context.save();
|
||||||
} catch (error: any) {
|
frameClip(targetFrame, context, renderConfig, appState);
|
||||||
console.error(error);
|
_renderElement(element);
|
||||||
|
context.restore();
|
||||||
|
} else {
|
||||||
|
_renderElement(element);
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
|
_renderElement(element);
|
||||||
// render embeddables on top
|
}
|
||||||
visibleElements
|
}
|
||||||
.filter((el) => isEmbeddableOrFrameLabel(el))
|
|
||||||
.forEach((element) => {
|
|
||||||
try {
|
|
||||||
const render = () => {
|
|
||||||
renderElement(element, rc, context, renderConfig, appState);
|
|
||||||
|
|
||||||
if (
|
|
||||||
isEmbeddableElement(element) &&
|
|
||||||
(isExporting || !element.validated) &&
|
|
||||||
element.width &&
|
|
||||||
element.height
|
|
||||||
) {
|
|
||||||
const label = createPlaceholderEmbeddableLabel(element);
|
|
||||||
renderElement(label, rc, context, renderConfig, appState);
|
|
||||||
}
|
|
||||||
if (!isExporting) {
|
|
||||||
renderLinkIcon(element, context, appState);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// - when exporting the whole canvas, we DO NOT apply clipping
|
|
||||||
// - when we are exporting a particular frame, apply clipping
|
|
||||||
// if the containing frame is not selected, apply clipping
|
|
||||||
const frameId = element.frameId || appState.frameToHighlight?.id;
|
|
||||||
|
|
||||||
if (
|
|
||||||
frameId &&
|
|
||||||
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
|
|
||||||
(!renderConfig.isExporting &&
|
|
||||||
appState.frameRendering.enabled &&
|
|
||||||
appState.frameRendering.clip))
|
|
||||||
) {
|
|
||||||
context.save();
|
|
||||||
|
|
||||||
const frame = getTargetFrame(element, appState);
|
|
||||||
|
|
||||||
if (frame && isElementInFrame(element, elements, appState)) {
|
|
||||||
frameClip(frame, context, renderConfig, appState);
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
context.restore();
|
|
||||||
} else {
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** throttled to animation framerate */
|
/** throttled to animation framerate */
|
||||||
@@ -1152,7 +1123,7 @@ const renderTransformHandles = (
|
|||||||
|
|
||||||
const renderSelectionBorder = (
|
const renderSelectionBorder = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState | StaticCanvasAppState,
|
||||||
elementProperties: {
|
elementProperties: {
|
||||||
angle: number;
|
angle: number;
|
||||||
elementX1: number;
|
elementX1: number;
|
||||||
@@ -1298,7 +1269,7 @@ const renderFrameHighlight = (
|
|||||||
const height = y2 - y1;
|
const height = y2 - y1;
|
||||||
|
|
||||||
context.strokeStyle = "rgb(0,118,255)";
|
context.strokeStyle = "rgb(0,118,255)";
|
||||||
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / appState.zoom.value;
|
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||||
|
|
||||||
context.save();
|
context.save();
|
||||||
context.translate(appState.scrollX, appState.scrollY);
|
context.translate(appState.scrollX, appState.scrollY);
|
||||||
@@ -1317,6 +1288,23 @@ const renderFrameHighlight = (
|
|||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
|
||||||
|
const [elementX1, elementY1, elementX2, elementY2] =
|
||||||
|
getCommonBounds(elements);
|
||||||
|
return {
|
||||||
|
angle: 0,
|
||||||
|
elementX1,
|
||||||
|
elementX2,
|
||||||
|
elementY1,
|
||||||
|
elementY2,
|
||||||
|
selectionColors: ["rgb(0,118,255)"],
|
||||||
|
dashed: false,
|
||||||
|
cx: elementX1 + (elementX2 - elementX1) / 2,
|
||||||
|
cy: elementY1 + (elementY2 - elementY1) / 2,
|
||||||
|
activeEmbeddable: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const renderElementsBoxHighlight = (
|
const renderElementsBoxHighlight = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
@@ -1330,37 +1318,28 @@ const renderElementsBoxHighlight = (
|
|||||||
(element) => element.groupIds.length > 0,
|
(element) => element.groupIds.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
|
const processedGroupIds = new Set<string>();
|
||||||
const [elementX1, elementY1, elementX2, elementY2] =
|
|
||||||
getCommonBounds(elements);
|
|
||||||
return {
|
|
||||||
angle: 0,
|
|
||||||
elementX1,
|
|
||||||
elementX2,
|
|
||||||
elementY1,
|
|
||||||
elementY2,
|
|
||||||
selectionColors: ["rgb(0,118,255)"],
|
|
||||||
dashed: false,
|
|
||||||
cx: elementX1 + (elementX2 - elementX1) / 2,
|
|
||||||
cy: elementY1 + (elementY2 - elementY1) / 2,
|
|
||||||
activeEmbeddable: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSelectionForGroupId = (groupId: GroupId) => {
|
const getSelectionForGroupId = (groupId: GroupId) => {
|
||||||
const groupElements = getElementsInGroup(elements, groupId);
|
if (!processedGroupIds.has(groupId)) {
|
||||||
return getSelectionFromElements(groupElements);
|
const groupElements = getElementsInGroup(elements, groupId);
|
||||||
|
processedGroupIds.add(groupId);
|
||||||
|
return getSelectionFromElements(groupElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
|
Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
|
||||||
.filter(([id, isSelected]) => isSelected)
|
.filter(([id, isSelected]) => isSelected)
|
||||||
.map(([id, isSelected]) => id)
|
.map(([id, isSelected]) => id)
|
||||||
.map((groupId) => getSelectionForGroupId(groupId))
|
.map((groupId) => getSelectionForGroupId(groupId))
|
||||||
|
.filter((selection) => selection)
|
||||||
.concat(
|
.concat(
|
||||||
individualElements.map((element) => getSelectionFromElements([element])),
|
individualElements.map((element) => getSelectionFromElements([element])),
|
||||||
)
|
)
|
||||||
.forEach((selection) =>
|
.forEach((selection) =>
|
||||||
renderSelectionBorder(context, appState, selection),
|
renderSelectionBorder(context, appState, selection!),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1454,24 +1433,29 @@ export const renderSceneToSvg = (
|
|||||||
{
|
{
|
||||||
offsetX = 0,
|
offsetX = 0,
|
||||||
offsetY = 0,
|
offsetY = 0,
|
||||||
exportWithDarkMode = false,
|
exportWithDarkMode,
|
||||||
exportingFrameId = null,
|
|
||||||
renderEmbeddables,
|
renderEmbeddables,
|
||||||
|
frameRendering,
|
||||||
}: {
|
}: {
|
||||||
offsetX?: number;
|
offsetX?: number;
|
||||||
offsetY?: number;
|
offsetY?: number;
|
||||||
exportWithDarkMode?: boolean;
|
exportWithDarkMode: boolean;
|
||||||
exportingFrameId?: string | null;
|
renderEmbeddables: boolean;
|
||||||
renderEmbeddables?: boolean;
|
frameRendering: AppState["frameRendering"];
|
||||||
} = {},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (!svgRoot) {
|
if (!svgRoot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderConfig = {
|
||||||
|
exportWithDarkMode,
|
||||||
|
renderEmbeddables,
|
||||||
|
frameRendering,
|
||||||
|
};
|
||||||
// render elements
|
// render elements
|
||||||
elements
|
elements
|
||||||
.filter((el) => !isEmbeddableOrFrameLabel(el))
|
.filter((el) => !isEmbeddableOrLabel(el))
|
||||||
.forEach((element) => {
|
.forEach((element) => {
|
||||||
if (!element.isDeleted) {
|
if (!element.isDeleted) {
|
||||||
try {
|
try {
|
||||||
@@ -1482,9 +1466,7 @@ export const renderSceneToSvg = (
|
|||||||
files,
|
files,
|
||||||
element.x + offsetX,
|
element.x + offsetX,
|
||||||
element.y + offsetY,
|
element.y + offsetY,
|
||||||
exportWithDarkMode,
|
renderConfig,
|
||||||
exportingFrameId,
|
|
||||||
renderEmbeddables,
|
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -1505,9 +1487,7 @@ export const renderSceneToSvg = (
|
|||||||
files,
|
files,
|
||||||
element.x + offsetX,
|
element.x + offsetX,
|
||||||
element.y + offsetY,
|
element.y + offsetY,
|
||||||
exportWithDarkMode,
|
renderConfig,
|
||||||
exportingFrameId,
|
|
||||||
renderEmbeddables,
|
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
+21
-5
@@ -66,16 +66,29 @@ class Scene {
|
|||||||
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
|
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
|
||||||
private static sceneMapById = new Map<string, Scene>();
|
private static sceneMapById = new Map<string, Scene>();
|
||||||
|
|
||||||
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
|
static mapElementToScene(
|
||||||
|
elementKey: ElementKey,
|
||||||
|
scene: Scene,
|
||||||
|
/**
|
||||||
|
* needed because of frame exporting hack.
|
||||||
|
* elementId:Scene mapping will be removed completely, soon.
|
||||||
|
*/
|
||||||
|
mapElementIds = true,
|
||||||
|
) {
|
||||||
if (isIdKey(elementKey)) {
|
if (isIdKey(elementKey)) {
|
||||||
|
if (!mapElementIds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// for cases where we don't have access to the element object
|
// for cases where we don't have access to the element object
|
||||||
// (e.g. restore serialized appState with id references)
|
// (e.g. restore serialized appState with id references)
|
||||||
this.sceneMapById.set(elementKey, scene);
|
this.sceneMapById.set(elementKey, scene);
|
||||||
} else {
|
} else {
|
||||||
this.sceneMapByElement.set(elementKey, scene);
|
this.sceneMapByElement.set(elementKey, scene);
|
||||||
// if mapping element objects, also cache the id string when later
|
if (!mapElementIds) {
|
||||||
// looking up by id alone
|
// if mapping element objects, also cache the id string when later
|
||||||
this.sceneMapById.set(elementKey.id, scene);
|
// looking up by id alone
|
||||||
|
this.sceneMapById.set(elementKey.id, scene);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +230,10 @@ class Scene {
|
|||||||
return didChange;
|
return didChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
|
replaceAllElements(
|
||||||
|
nextElements: readonly ExcalidrawElement[],
|
||||||
|
mapElementIds = true,
|
||||||
|
) {
|
||||||
this.elements = nextElements;
|
this.elements = nextElements;
|
||||||
const nextFrames: ExcalidrawFrameElement[] = [];
|
const nextFrames: ExcalidrawFrameElement[] = [];
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
|
|||||||
+21
-8
@@ -14,18 +14,34 @@ import { generateFreeDrawShape } from "../renderer/renderElement";
|
|||||||
import { isTransparent, assertNever } from "../utils";
|
import { isTransparent, assertNever } from "../utils";
|
||||||
import { simplify } from "points-on-curve";
|
import { simplify } from "points-on-curve";
|
||||||
import { ROUGHNESS } from "../constants";
|
import { ROUGHNESS } from "../constants";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
import { canChangeRoundness } from "./comparisons";
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||||
|
|
||||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||||
|
|
||||||
function adjustRoughness(size: number, roughness: number): number {
|
function adjustRoughness(element: ExcalidrawElement): number {
|
||||||
if (size >= 50) {
|
const roughness = element.roughness;
|
||||||
|
|
||||||
|
const maxSize = Math.max(element.width, element.height);
|
||||||
|
const minSize = Math.min(element.width, element.height);
|
||||||
|
|
||||||
|
// don't reduce roughness if
|
||||||
|
if (
|
||||||
|
// both sides relatively big
|
||||||
|
(minSize >= 20 && maxSize >= 50) ||
|
||||||
|
// is round & both sides above 15px
|
||||||
|
(minSize >= 15 &&
|
||||||
|
!!element.roundness &&
|
||||||
|
canChangeRoundness(element.type)) ||
|
||||||
|
// relatively long linear element
|
||||||
|
(isLinearElement(element) && maxSize >= 50)
|
||||||
|
) {
|
||||||
return roughness;
|
return roughness;
|
||||||
}
|
}
|
||||||
const factor = 2 + (50 - size) / 10;
|
|
||||||
|
|
||||||
return roughness / factor;
|
return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateRoughOptions = (
|
export const generateRoughOptions = (
|
||||||
@@ -54,10 +70,7 @@ export const generateRoughOptions = (
|
|||||||
// calculate them (and we don't want the fills to be modified)
|
// calculate them (and we don't want the fills to be modified)
|
||||||
fillWeight: element.strokeWidth / 2,
|
fillWeight: element.strokeWidth / 2,
|
||||||
hachureGap: element.strokeWidth * 4,
|
hachureGap: element.strokeWidth * 4,
|
||||||
roughness: adjustRoughness(
|
roughness: adjustRoughness(element),
|
||||||
Math.min(element.width, element.height),
|
|
||||||
element.roughness,
|
|
||||||
),
|
|
||||||
stroke: element.strokeColor,
|
stroke: element.strokeColor,
|
||||||
preserveVertices:
|
preserveVertices:
|
||||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||||
|
|||||||
+253
-75
@@ -1,24 +1,176 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
Bounds,
|
Bounds,
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
} from "../element/bounds";
|
} from "../element/bounds";
|
||||||
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
|
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
|
||||||
import { distance, isOnlyExportingSingleFrame } from "../utils";
|
import { cloneJSON, distance, getFontString } from "../utils";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
|
import {
|
||||||
|
DEFAULT_EXPORT_PADDING,
|
||||||
|
FONT_FAMILY,
|
||||||
|
FRAME_STYLE,
|
||||||
|
SVG_NS,
|
||||||
|
THEME_FILTER,
|
||||||
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { serializeAsJSON } from "../data/json";
|
import { serializeAsJSON } from "../data/json";
|
||||||
import {
|
import {
|
||||||
getInitializedImageElements,
|
getInitializedImageElements,
|
||||||
updateImageCache,
|
updateImageCache,
|
||||||
} from "../element/image";
|
} from "../element/image";
|
||||||
|
import { elementsOverlappingBBox } from "../packages/withinBounds";
|
||||||
|
import { getFrameElements, getRootElements } from "../frame";
|
||||||
|
import { isFrameElement, newTextElement } from "../element";
|
||||||
|
import { Mutable } from "../utility-types";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import Scene from "./Scene";
|
import Scene from "./Scene";
|
||||||
|
|
||||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||||
|
|
||||||
|
// getContainerElement and getBoundTextElement and potentially other helpers
|
||||||
|
// depend on `Scene` which will not be available when these pure utils are
|
||||||
|
// called outside initialized Excalidraw editor instance or even if called
|
||||||
|
// from inside Excalidraw if the elements were never cached by Scene (e.g.
|
||||||
|
// for library elements).
|
||||||
|
//
|
||||||
|
// As such, before passing the elements down, we need to initialize a custom
|
||||||
|
// Scene instance and assign them to it.
|
||||||
|
//
|
||||||
|
// FIXME This is a super hacky workaround and we'll need to rewrite this soon.
|
||||||
|
const __createSceneForElementsHack__ = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
const scene = new Scene();
|
||||||
|
// we can't duplicate elements to regenerate ids because we need the
|
||||||
|
// orig ids when embedding. So we do another hack of not mapping element
|
||||||
|
// ids to Scene instances so that we don't override the editor elements
|
||||||
|
// mapping.
|
||||||
|
// We still need to clone the objects themselves to regen references.
|
||||||
|
scene.replaceAllElements(cloneJSON(elements), false);
|
||||||
|
return scene;
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
||||||
|
if (element.width <= maxWidth) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.font = getFontString({
|
||||||
|
fontFamily: element.fontFamily,
|
||||||
|
fontSize: element.fontSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
let text = element.text;
|
||||||
|
|
||||||
|
const metrics = ctx.measureText(text);
|
||||||
|
|
||||||
|
if (metrics.width > maxWidth) {
|
||||||
|
// we iterate from the right, removing characters one by one instead
|
||||||
|
// of bulding the string up. This assumes that it's more likely
|
||||||
|
// your frame names will overflow by not that many characters
|
||||||
|
// (if ever), so it sohuld be faster this way.
|
||||||
|
for (let i = text.length; i > 0; i--) {
|
||||||
|
const newText = `${text.slice(0, i)}...`;
|
||||||
|
if (ctx.measureText(newText).width <= maxWidth) {
|
||||||
|
text = newText;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newElementWith(element, { text, width: maxWidth });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When exporting frames, we need to render frame labels which are currently
|
||||||
|
* being rendered in DOM when editing. Adding the labels as regular text
|
||||||
|
* elements seems like a simple hack. In the future we'll want to move to
|
||||||
|
* proper canvas rendering, even within editor (instead of DOM).
|
||||||
|
*/
|
||||||
|
const addFrameLabelsAsTextElements = (
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
opts: Pick<AppState, "exportWithDarkMode">,
|
||||||
|
) => {
|
||||||
|
const nextElements: NonDeletedExcalidrawElement[] = [];
|
||||||
|
let frameIdx = 0;
|
||||||
|
for (const element of elements) {
|
||||||
|
if (isFrameElement(element)) {
|
||||||
|
frameIdx++;
|
||||||
|
let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
|
||||||
|
x: element.x,
|
||||||
|
y: element.y - FRAME_STYLE.nameOffsetY,
|
||||||
|
fontFamily: FONT_FAMILY.Assistant,
|
||||||
|
fontSize: FRAME_STYLE.nameFontSize,
|
||||||
|
lineHeight:
|
||||||
|
FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"],
|
||||||
|
strokeColor: opts.exportWithDarkMode
|
||||||
|
? FRAME_STYLE.nameColorDarkTheme
|
||||||
|
: FRAME_STYLE.nameColorLightTheme,
|
||||||
|
text: element.name || `Frame ${frameIdx}`,
|
||||||
|
});
|
||||||
|
textElement.y -= textElement.height;
|
||||||
|
|
||||||
|
textElement = truncateText(textElement, element.width);
|
||||||
|
|
||||||
|
nextElements.push(textElement);
|
||||||
|
}
|
||||||
|
nextElements.push(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameRenderingConfig = (
|
||||||
|
exportingFrame: ExcalidrawFrameElement | null,
|
||||||
|
frameRendering: AppState["frameRendering"] | null,
|
||||||
|
): AppState["frameRendering"] => {
|
||||||
|
frameRendering = frameRendering || getDefaultAppState().frameRendering;
|
||||||
|
return {
|
||||||
|
enabled: exportingFrame ? true : frameRendering.enabled,
|
||||||
|
outline: exportingFrame ? false : frameRendering.outline,
|
||||||
|
name: exportingFrame ? false : frameRendering.name,
|
||||||
|
clip: exportingFrame ? true : frameRendering.clip,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareElementsForRender = ({
|
||||||
|
elements,
|
||||||
|
exportingFrame,
|
||||||
|
frameRendering,
|
||||||
|
exportWithDarkMode,
|
||||||
|
}: {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
exportingFrame: ExcalidrawFrameElement | null | undefined;
|
||||||
|
frameRendering: AppState["frameRendering"];
|
||||||
|
exportWithDarkMode: AppState["exportWithDarkMode"];
|
||||||
|
}) => {
|
||||||
|
let nextElements: readonly ExcalidrawElement[];
|
||||||
|
|
||||||
|
if (exportingFrame) {
|
||||||
|
nextElements = elementsOverlappingBBox({
|
||||||
|
elements,
|
||||||
|
bounds: exportingFrame,
|
||||||
|
type: "overlap",
|
||||||
|
});
|
||||||
|
} else if (frameRendering.enabled && frameRendering.name) {
|
||||||
|
nextElements = addFrameLabelsAsTextElements(elements, {
|
||||||
|
exportWithDarkMode,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nextElements = elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextElements;
|
||||||
|
};
|
||||||
|
|
||||||
export const exportToCanvas = async (
|
export const exportToCanvas = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -27,10 +179,12 @@ export const exportToCanvas = async (
|
|||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
|
exportingFrame,
|
||||||
}: {
|
}: {
|
||||||
exportBackground: boolean;
|
exportBackground: boolean;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
|
exportingFrame?: ExcalidrawFrameElement | null;
|
||||||
},
|
},
|
||||||
createCanvas: (
|
createCanvas: (
|
||||||
width: number,
|
width: number,
|
||||||
@@ -42,7 +196,29 @@ export const exportToCanvas = async (
|
|||||||
return { canvas, scale: appState.exportScale };
|
return { canvas, scale: appState.exportScale };
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
|
const tempScene = __createSceneForElementsHack__(elements);
|
||||||
|
elements = tempScene.getNonDeletedElements();
|
||||||
|
|
||||||
|
const frameRendering = getFrameRenderingConfig(
|
||||||
|
exportingFrame ?? null,
|
||||||
|
appState.frameRendering ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsForRender = prepareElementsForRender({
|
||||||
|
elements,
|
||||||
|
exportingFrame,
|
||||||
|
exportWithDarkMode: appState.exportWithDarkMode,
|
||||||
|
frameRendering,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exportingFrame) {
|
||||||
|
exportPadding = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [minX, minY, width, height] = getCanvasSize(
|
||||||
|
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
||||||
|
exportPadding,
|
||||||
|
);
|
||||||
|
|
||||||
const { canvas, scale = 1 } = createCanvas(width, height);
|
const { canvas, scale = 1 } = createCanvas(width, height);
|
||||||
|
|
||||||
@@ -50,25 +226,24 @@ export const exportToCanvas = async (
|
|||||||
|
|
||||||
const { imageCache } = await updateImageCache({
|
const { imageCache } = await updateImageCache({
|
||||||
imageCache: new Map(),
|
imageCache: new Map(),
|
||||||
fileIds: getInitializedImageElements(elements).map(
|
fileIds: getInitializedImageElements(elementsForRender).map(
|
||||||
(element) => element.fileId,
|
(element) => element.fileId,
|
||||||
),
|
),
|
||||||
files,
|
files,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
|
||||||
|
|
||||||
renderStaticScene({
|
renderStaticScene({
|
||||||
canvas,
|
canvas,
|
||||||
rc: rough.canvas(canvas),
|
rc: rough.canvas(canvas),
|
||||||
elements,
|
elements: elementsForRender,
|
||||||
visibleElements: elements,
|
visibleElements: elementsForRender,
|
||||||
scale,
|
scale,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
frameRendering,
|
||||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||||
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
|
scrollX: -minX + exportPadding,
|
||||||
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
|
scrollY: -minY + exportPadding,
|
||||||
zoom: defaultAppState.zoom,
|
zoom: defaultAppState.zoom,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
theme: appState.exportWithDarkMode ? "dark" : "light",
|
theme: appState.exportWithDarkMode ? "dark" : "light",
|
||||||
@@ -80,6 +255,8 @@ export const exportToCanvas = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tempScene.destroy();
|
||||||
|
|
||||||
return canvas;
|
return canvas;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,35 +269,67 @@ export const exportToSvg = async (
|
|||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
exportWithDarkMode?: boolean;
|
exportWithDarkMode?: boolean;
|
||||||
exportEmbedScene?: boolean;
|
exportEmbedScene?: boolean;
|
||||||
renderFrame?: boolean;
|
frameRendering?: AppState["frameRendering"];
|
||||||
},
|
},
|
||||||
files: BinaryFiles | null,
|
files: BinaryFiles | null,
|
||||||
opts?: {
|
opts?: {
|
||||||
serializeAsJSON?: () => string;
|
|
||||||
renderEmbeddables?: boolean;
|
renderEmbeddables?: boolean;
|
||||||
|
exportingFrame?: ExcalidrawFrameElement | null;
|
||||||
},
|
},
|
||||||
): Promise<SVGSVGElement> => {
|
): Promise<SVGSVGElement> => {
|
||||||
const {
|
const tempScene = __createSceneForElementsHack__(elements);
|
||||||
|
elements = tempScene.getNonDeletedElements();
|
||||||
|
|
||||||
|
const frameRendering = getFrameRenderingConfig(
|
||||||
|
opts?.exportingFrame ?? null,
|
||||||
|
appState.frameRendering ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
let {
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
|
exportWithDarkMode = false,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
exportScale = 1,
|
exportScale = 1,
|
||||||
exportEmbedScene,
|
exportEmbedScene,
|
||||||
} = appState;
|
} = appState;
|
||||||
|
|
||||||
|
const { exportingFrame = null } = opts || {};
|
||||||
|
|
||||||
|
const elementsForRender = prepareElementsForRender({
|
||||||
|
elements,
|
||||||
|
exportingFrame,
|
||||||
|
exportWithDarkMode,
|
||||||
|
frameRendering,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exportingFrame) {
|
||||||
|
exportPadding = 0;
|
||||||
|
}
|
||||||
|
|
||||||
let metadata = "";
|
let metadata = "";
|
||||||
|
|
||||||
|
// we need to serialize the "original" elements before we put them through
|
||||||
|
// the tempScene hack which duplicates and regenerates ids
|
||||||
if (exportEmbedScene) {
|
if (exportEmbedScene) {
|
||||||
try {
|
try {
|
||||||
metadata = await (
|
metadata = await (
|
||||||
await import(/* webpackChunkName: "image" */ "../../src/data/image")
|
await import(/* webpackChunkName: "image" */ "../../src/data/image")
|
||||||
).encodeSvgMetadata({
|
).encodeSvgMetadata({
|
||||||
text: opts?.serializeAsJSON
|
// when embedding scene, we want to embed the origionally supplied
|
||||||
? opts?.serializeAsJSON?.()
|
// elements which don't contain the temp frame labels.
|
||||||
: serializeAsJSON(elements, appState, files || {}, "local"),
|
// 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) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
|
|
||||||
|
const [minX, minY, width, height] = getCanvasSize(
|
||||||
|
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
||||||
|
exportPadding,
|
||||||
|
);
|
||||||
|
|
||||||
// initialize SVG root
|
// initialize SVG root
|
||||||
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
||||||
@@ -129,7 +338,7 @@ export const exportToSvg = async (
|
|||||||
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
svgRoot.setAttribute("width", `${width * exportScale}`);
|
svgRoot.setAttribute("width", `${width * exportScale}`);
|
||||||
svgRoot.setAttribute("height", `${height * exportScale}`);
|
svgRoot.setAttribute("height", `${height * exportScale}`);
|
||||||
if (appState.exportWithDarkMode) {
|
if (exportWithDarkMode) {
|
||||||
svgRoot.setAttribute("filter", THEME_FILTER);
|
svgRoot.setAttribute("filter", THEME_FILTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,33 +357,23 @@ export const exportToSvg = async (
|
|||||||
assetPath = `${assetPath}/dist/excalidraw-assets/`;
|
assetPath = `${assetPath}/dist/excalidraw-assets/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// do not apply clipping when we're exporting the whole scene
|
const offsetX = -minX + exportPadding;
|
||||||
const isExportingWholeCanvas =
|
const offsetY = -minY + exportPadding;
|
||||||
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
|
|
||||||
elements.length;
|
|
||||||
|
|
||||||
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
const frameElements = getFrameElements(elements);
|
||||||
|
|
||||||
const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
|
|
||||||
const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
|
|
||||||
|
|
||||||
const exportingFrame =
|
|
||||||
isExportingWholeCanvas || !onlyExportingSingleFrame
|
|
||||||
? undefined
|
|
||||||
: elements.find((element) => element.type === "frame");
|
|
||||||
|
|
||||||
let exportingFrameClipPath = "";
|
let exportingFrameClipPath = "";
|
||||||
if (exportingFrame) {
|
for (const frame of frameElements) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
|
||||||
const cx = (x2 - x1) / 2 - (exportingFrame.x - x1);
|
const cx = (x2 - x1) / 2 - (frame.x - x1);
|
||||||
const cy = (y2 - y1) / 2 - (exportingFrame.y - y1);
|
const cy = (y2 - y1) / 2 - (frame.y - y1);
|
||||||
|
|
||||||
exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
|
exportingFrameClipPath += `<clipPath id=${frame.id}>
|
||||||
<rect transform="translate(${exportingFrame.x + offsetX} ${
|
<rect transform="translate(${frame.x + offsetX} ${
|
||||||
exportingFrame.y + offsetY
|
frame.y + offsetY
|
||||||
}) rotate(${exportingFrame.angle} ${cx} ${cy})"
|
}) rotate(${frame.angle} ${cx} ${cy})"
|
||||||
width="${exportingFrame.width}"
|
width="${frame.width}"
|
||||||
height="${exportingFrame.height}"
|
height="${frame.height}"
|
||||||
>
|
>
|
||||||
</rect>
|
</rect>
|
||||||
</clipPath>`;
|
</clipPath>`;
|
||||||
@@ -193,6 +392,10 @@ export const exportToSvg = async (
|
|||||||
font-family: "Cascadia";
|
font-family: "Cascadia";
|
||||||
src: url("${assetPath}Cascadia.woff2");
|
src: url("${assetPath}Cascadia.woff2");
|
||||||
}
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Assistant";
|
||||||
|
src: url("${assetPath}Assistant-Regular.woff2");
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
${exportingFrameClipPath}
|
${exportingFrameClipPath}
|
||||||
</defs>
|
</defs>
|
||||||
@@ -210,14 +413,16 @@ export const exportToSvg = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rsvg = rough.svg(svgRoot);
|
const rsvg = rough.svg(svgRoot);
|
||||||
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, {
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
exportWithDarkMode: appState.exportWithDarkMode,
|
exportWithDarkMode,
|
||||||
exportingFrameId: exportingFrame?.id || null,
|
renderEmbeddables: opts?.renderEmbeddables ?? false,
|
||||||
renderEmbeddables: opts?.renderEmbeddables,
|
frameRendering,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tempScene.destroy();
|
||||||
|
|
||||||
return svgRoot;
|
return svgRoot;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,36 +431,9 @@ const getCanvasSize = (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
exportPadding: number,
|
exportPadding: number,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
// we should decide if we are exporting the whole canvas
|
|
||||||
// if so, we are not clipping elements in the frame
|
|
||||||
// and therefore, we should not do anything special
|
|
||||||
|
|
||||||
const isExportingWholeCanvas =
|
|
||||||
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
|
|
||||||
elements.length;
|
|
||||||
|
|
||||||
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
|
||||||
|
|
||||||
if (!isExportingWholeCanvas || onlyExportingSingleFrame) {
|
|
||||||
const frames = elements.filter((element) => element.type === "frame");
|
|
||||||
|
|
||||||
const exportedFrameIds = frames.reduce((acc, frame) => {
|
|
||||||
acc[frame.id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, true>);
|
|
||||||
|
|
||||||
// elements in a frame do not affect the canvas size if we're not exporting
|
|
||||||
// the whole canvas
|
|
||||||
elements = elements.filter(
|
|
||||||
(element) => !exportedFrameIds[element.frameId ?? ""],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
const width =
|
const width = distance(minX, maxX) + exportPadding * 2;
|
||||||
distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
const height = distance(minY, maxY) + exportPadding * 2;
|
||||||
const height =
|
|
||||||
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
|
||||||
|
|
||||||
return [minX, minY, width, height];
|
return [minX, minY, width, height];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { isBoundToContainer } from "../element/typeChecks";
|
|||||||
import {
|
import {
|
||||||
elementOverlapsWithFrame,
|
elementOverlapsWithFrame,
|
||||||
getContainingFrame,
|
getContainingFrame,
|
||||||
getFrameElements,
|
getFrameChildren,
|
||||||
} from "../frame";
|
} from "../frame";
|
||||||
import { isShallowEqual } from "../utils";
|
import { isShallowEqual } from "../utils";
|
||||||
import { isElementInViewport } from "../element/sizeHelpers";
|
import { isElementInViewport } from "../element/sizeHelpers";
|
||||||
@@ -191,7 +191,7 @@ export const getSelectedElements = (
|
|||||||
const elementsToInclude: ExcalidrawElement[] = [];
|
const elementsToInclude: ExcalidrawElement[] = [];
|
||||||
selectedElements.forEach((element) => {
|
selectedElements.forEach((element) => {
|
||||||
if (element.type === "frame") {
|
if (element.type === "frame") {
|
||||||
getFrameElements(elements, element.id).forEach((e) =>
|
getFrameChildren(elements, element.id).forEach((e) =>
|
||||||
elementsToInclude.push(e),
|
elementsToInclude.push(e),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, fireEvent, render } from "./test-utils";
|
import { act, fireEvent, render, waitFor } from "./test-utils";
|
||||||
import { Excalidraw } from "../packages/excalidraw/index";
|
import { Excalidraw } from "../packages/excalidraw/index";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { expect, vi } from "vitest";
|
import { expect, vi } from "vitest";
|
||||||
@@ -111,7 +111,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
|||||||
|
|
||||||
it("should open mermaid popup when active tool is mermaid", async () => {
|
it("should open mermaid popup when active tool is mermaid", async () => {
|
||||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||||
|
await waitFor(() => dialog.querySelector("canvas"));
|
||||||
expect(dialog.outerHTML).toMatchSnapshot();
|
expect(dialog.outerHTML).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active too
|
|||||||
B --> C{Let me think}
|
B --> C{Let me think}
|
||||||
C -->|One| D[Laptop]
|
C -->|One| D[Laptop]
|
||||||
C -->|Two| E[iPhone]
|
C -->|Two| E[iPhone]
|
||||||
C -->|Three| F[Car]</textarea></div><div class=\\"dialog-mermaid-panels-preview\\"><label>Preview</label><div class=\\"dialog-mermaid-panels-preview-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"dialog-mermaid-panels-preview-canvas-container\\"></div></div></div></div><div class=\\"dialog-mermaid-buttons\\"><button type=\\"button\\" class=\\"excalidraw-button dialog-mermaid-insert\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></button></div></div></div></div></div></div>"
|
C -->|Three| F[Car]</textarea></div><div class=\\"dialog-mermaid-panels-preview\\"><label>Preview</label><div class=\\"dialog-mermaid-panels-preview-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"dialog-mermaid-panels-preview-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div></div></div><div class=\\"dialog-mermaid-buttons\\"><button type=\\"button\\" class=\\"excalidraw-button dialog-mermaid-insert\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></button></div></div></div></div></div></div>"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ exports[`export > exporting svg containing transformed images > svg export outpu
|
|||||||
font-family: \\"Cascadia\\";
|
font-family: \\"Cascadia\\";
|
||||||
src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
|
src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
|
||||||
}
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: \\"Assistant\\";
|
||||||
|
src: url(\\"https://excalidraw.com/Assistant-Regular.woff2\\");
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</defs>
|
</defs>
|
||||||
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
|
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\" data-id=\\"id1\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\" data-id=\\"id2\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\" data-id=\\"id3\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\" data-id=\\"id4\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
|
||||||
`;
|
`;
|
||||||
|
|||||||
+8
-11
@@ -27,6 +27,7 @@ import * as blob from "../data/blob";
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getBoundTextElementPosition } from "../element/textElement";
|
import { getBoundTextElementPosition } from "../element/textElement";
|
||||||
import { createPasteEvent } from "../clipboard";
|
import { createPasteEvent } from "../clipboard";
|
||||||
|
import { cloneJSON } from "../utils";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@@ -206,16 +207,14 @@ const checkElementsBoundingBox = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
|
const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
const originalElement = cloneJSON(h.elements[0]);
|
||||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||||
const newElement = h.elements[0];
|
const newElement = h.elements[0];
|
||||||
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkTwoPointsLineHorizontalFlip = async () => {
|
const checkTwoPointsLineHorizontalFlip = async () => {
|
||||||
const originalElement = JSON.parse(
|
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
|
||||||
JSON.stringify(h.elements[0]),
|
|
||||||
) as ExcalidrawLinearElement;
|
|
||||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||||
const newElement = h.elements[0] as ExcalidrawLinearElement;
|
const newElement = h.elements[0] as ExcalidrawLinearElement;
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -239,9 +238,7 @@ const checkTwoPointsLineHorizontalFlip = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkTwoPointsLineVerticalFlip = async () => {
|
const checkTwoPointsLineVerticalFlip = async () => {
|
||||||
const originalElement = JSON.parse(
|
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
|
||||||
JSON.stringify(h.elements[0]),
|
|
||||||
) as ExcalidrawLinearElement;
|
|
||||||
h.app.actionManager.executeAction(actionFlipVertical);
|
h.app.actionManager.executeAction(actionFlipVertical);
|
||||||
const newElement = h.elements[0] as ExcalidrawLinearElement;
|
const newElement = h.elements[0] as ExcalidrawLinearElement;
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -268,7 +265,7 @@ const checkRotatedHorizontalFlip = async (
|
|||||||
expectedAngle: number,
|
expectedAngle: number,
|
||||||
toleranceInPx: number = 0.00001,
|
toleranceInPx: number = 0.00001,
|
||||||
) => {
|
) => {
|
||||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
const originalElement = cloneJSON(h.elements[0]);
|
||||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||||
const newElement = h.elements[0];
|
const newElement = h.elements[0];
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -281,7 +278,7 @@ const checkRotatedVerticalFlip = async (
|
|||||||
expectedAngle: number,
|
expectedAngle: number,
|
||||||
toleranceInPx: number = 0.00001,
|
toleranceInPx: number = 0.00001,
|
||||||
) => {
|
) => {
|
||||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
const originalElement = cloneJSON(h.elements[0]);
|
||||||
h.app.actionManager.executeAction(actionFlipVertical);
|
h.app.actionManager.executeAction(actionFlipVertical);
|
||||||
const newElement = h.elements[0];
|
const newElement = h.elements[0];
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -291,7 +288,7 @@ const checkRotatedVerticalFlip = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
|
const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
const originalElement = cloneJSON(h.elements[0]);
|
||||||
|
|
||||||
h.app.actionManager.executeAction(actionFlipVertical);
|
h.app.actionManager.executeAction(actionFlipVertical);
|
||||||
|
|
||||||
@@ -300,7 +297,7 @@ const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkVerticalHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
|
const checkVerticalHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
const originalElement = cloneJSON(h.elements[0]);
|
||||||
|
|
||||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||||
h.app.actionManager.executeAction(actionFlipVertical);
|
h.app.actionManager.executeAction(actionFlipVertical);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { newElement, newTextElement, newLinearElement } from "../../element";
|
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||||
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
||||||
@@ -136,6 +137,8 @@ export class API {
|
|||||||
? ExcalidrawTextElement
|
? ExcalidrawTextElement
|
||||||
: T extends "image"
|
: T extends "image"
|
||||||
? ExcalidrawImageElement
|
? ExcalidrawImageElement
|
||||||
|
: T extends "frame"
|
||||||
|
? ExcalidrawFrameElement
|
||||||
: ExcalidrawGenericElement => {
|
: ExcalidrawGenericElement => {
|
||||||
let element: Mutable<ExcalidrawElement> = null!;
|
let element: Mutable<ExcalidrawElement> = null!;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ describe("event callbacks", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||||
await render(
|
await render(
|
||||||
<Excalidraw ref={(api) => excalidrawAPIPromise.resolve(api as any)} />,
|
<Excalidraw
|
||||||
|
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
excalidrawAPI = await excalidrawAPIPromise;
|
excalidrawAPI = await excalidrawAPIPromise;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ describe("exportToSvg", () => {
|
|||||||
expect(passedOptionsWhenDefault).toMatchSnapshot();
|
expect(passedOptionsWhenDefault).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("with deleted elements", async () => {
|
// FIXME the utils.exportToSvg no longer filters out deleted elements.
|
||||||
|
// It's already supposed to be passed non-deleted elements by we're not
|
||||||
|
// type-checking for it correctly.
|
||||||
|
it.skip("with deleted elements", async () => {
|
||||||
await utils.exportToSvg({
|
await utils.exportToSvg({
|
||||||
...diagramFactory({
|
...diagramFactory({
|
||||||
overrides: { appState: void 0 },
|
overrides: { appState: void 0 },
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -5,6 +5,10 @@ import {
|
|||||||
ellipseFixture,
|
ellipseFixture,
|
||||||
rectangleWithLinkFixture,
|
rectangleWithLinkFixture,
|
||||||
} from "../fixtures/elementFixture";
|
} from "../fixtures/elementFixture";
|
||||||
|
import { API } from "../helpers/api";
|
||||||
|
import { exportToCanvas, exportToSvg } from "../../packages/utils";
|
||||||
|
import { FRAME_STYLE } from "../../constants";
|
||||||
|
import { prepareElementsForExport } from "../../data";
|
||||||
|
|
||||||
describe("exportToSvg", () => {
|
describe("exportToSvg", () => {
|
||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||||
@@ -127,3 +131,280 @@ describe("exportToSvg", () => {
|
|||||||
expect(svgElement.innerHTML).toMatchSnapshot();
|
expect(svgElement.innerHTML).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("exporting frames", () => {
|
||||||
|
const getFrameNameHeight = (exportType: "canvas" | "svg") => {
|
||||||
|
const height =
|
||||||
|
FRAME_STYLE.nameFontSize * FRAME_STYLE.nameLineHeight +
|
||||||
|
FRAME_STYLE.nameOffsetY;
|
||||||
|
// canvas truncates dimensions to integers
|
||||||
|
if (exportType === "canvas") {
|
||||||
|
return Math.trunc(height);
|
||||||
|
}
|
||||||
|
return height;
|
||||||
|
};
|
||||||
|
|
||||||
|
// a few tests with exportToCanvas (where we can't inspect elements)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("exportToCanvas", () => {
|
||||||
|
it("exporting canvas with a single frame shouldn't crop if not exporting frame directly", async () => {
|
||||||
|
const elements = [
|
||||||
|
API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}),
|
||||||
|
API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const canvas = await exportToCanvas({
|
||||||
|
elements,
|
||||||
|
files: null,
|
||||||
|
exportPadding: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(canvas.width).toEqual(200);
|
||||||
|
expect(canvas.height).toEqual(100 + getFrameNameHeight("canvas"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exporting canvas with a single frame should crop when exporting frame directly", async () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const elements = [
|
||||||
|
frame,
|
||||||
|
API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const canvas = await exportToCanvas({
|
||||||
|
elements,
|
||||||
|
files: null,
|
||||||
|
exportPadding: 0,
|
||||||
|
exportingFrame: frame,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(canvas.width).toEqual(frame.width);
|
||||||
|
expect(canvas.height).toEqual(frame.height);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// exportToSvg (so we can test for element existence)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("exportToSvg", () => {
|
||||||
|
it("exporting frame should include overlapping elements, but crop to frame", async () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const frameChild = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 50,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
const rectOverlapping = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 50,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: [rectOverlapping, frame, frameChild],
|
||||||
|
files: null,
|
||||||
|
exportPadding: 0,
|
||||||
|
exportingFrame: frame,
|
||||||
|
});
|
||||||
|
|
||||||
|
// frame itself isn't exported
|
||||||
|
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
|
||||||
|
// frame child is exported
|
||||||
|
expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
|
||||||
|
// overlapping element is exported
|
||||||
|
expect(
|
||||||
|
svg.querySelector(`[data-id="${rectOverlapping.id}"]`),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
expect(svg.getAttribute("width")).toBe(frame.width.toString());
|
||||||
|
expect(svg.getAttribute("height")).toBe(frame.height.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter non-overlapping elements when exporting a frame", async () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const frameChild = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 50,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
const elementOutside = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 200,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: [frameChild, frame, elementOutside],
|
||||||
|
files: null,
|
||||||
|
exportPadding: 0,
|
||||||
|
exportingFrame: frame,
|
||||||
|
});
|
||||||
|
|
||||||
|
// frame itself isn't exported
|
||||||
|
expect(svg.querySelector(`[data-id="${frame.id}"]`)).toBeNull();
|
||||||
|
// frame child is exported
|
||||||
|
expect(svg.querySelector(`[data-id="${frameChild.id}"]`)).not.toBeNull();
|
||||||
|
// non-overlapping element is not exported
|
||||||
|
expect(svg.querySelector(`[data-id="${elementOutside.id}"]`)).toBeNull();
|
||||||
|
|
||||||
|
expect(svg.getAttribute("width")).toBe(frame.width.toString());
|
||||||
|
expect(svg.getAttribute("height")).toBe(frame.height.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should export multiple frames when selected, excluding overlapping elements", async () => {
|
||||||
|
const frame1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const frame2 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 200,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const frame1Child = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 50,
|
||||||
|
frameId: frame1.id,
|
||||||
|
});
|
||||||
|
const frame2Child = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 200,
|
||||||
|
y: 0,
|
||||||
|
frameId: frame2.id,
|
||||||
|
});
|
||||||
|
const frame2Overlapping = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 350,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// low-level exportToSvg api expects elements to be pre-filtered, so let's
|
||||||
|
// use the filter we use in the editor
|
||||||
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
|
[frame1Child, frame1, frame2Child, frame2, frame2Overlapping],
|
||||||
|
{
|
||||||
|
selectedElementIds: { [frame1.id]: true, [frame2.id]: true },
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: exportedElements,
|
||||||
|
files: null,
|
||||||
|
exportPadding: 0,
|
||||||
|
exportingFrame,
|
||||||
|
});
|
||||||
|
|
||||||
|
// frames themselves should be exported when multiple frames selected
|
||||||
|
expect(svg.querySelector(`[data-id="${frame1.id}"]`)).not.toBeNull();
|
||||||
|
expect(svg.querySelector(`[data-id="${frame2.id}"]`)).not.toBeNull();
|
||||||
|
// children should be epxorted
|
||||||
|
expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull();
|
||||||
|
expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).not.toBeNull();
|
||||||
|
// overlapping elements or non-overlapping elements should not be exported
|
||||||
|
expect(
|
||||||
|
svg.querySelector(`[data-id="${frame2Overlapping.id}"]`),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(svg.getAttribute("width")).toBe(
|
||||||
|
(frame2.x + frame2.width).toString(),
|
||||||
|
);
|
||||||
|
expect(svg.getAttribute("height")).toBe(
|
||||||
|
(frame2.y + frame2.height + getFrameNameHeight("svg")).toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render frame alone when not selected", async () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// low-level exportToSvg api expects elements to be pre-filtered, so let's
|
||||||
|
// use the filter we use in the editor
|
||||||
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
|
[frame],
|
||||||
|
{
|
||||||
|
selectedElementIds: {},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: exportedElements,
|
||||||
|
files: null,
|
||||||
|
exportPadding: 0,
|
||||||
|
exportingFrame,
|
||||||
|
});
|
||||||
|
|
||||||
|
// frame itself isn't exported
|
||||||
|
expect(svg.querySelector(`[data-id="${frame.id}"]`)).not.toBeNull();
|
||||||
|
|
||||||
|
expect(svg.getAttribute("width")).toBe(frame.width.toString());
|
||||||
|
expect(svg.getAttribute("height")).toBe(
|
||||||
|
(frame.height + getFrameNameHeight("svg")).toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -173,14 +173,18 @@ export const withExcalidrawDimensions = async (
|
|||||||
) => {
|
) => {
|
||||||
mockBoundingClientRect(dimensions);
|
mockBoundingClientRect(dimensions);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
h.app.refreshViewportBreakpoints();
|
||||||
|
// @ts-ignore
|
||||||
|
h.app.refreshEditorBreakpoints();
|
||||||
window.h.app.refresh();
|
window.h.app.refresh();
|
||||||
|
|
||||||
await cb();
|
await cb();
|
||||||
|
|
||||||
restoreOriginalGetBoundingClientRect();
|
restoreOriginalGetBoundingClientRect();
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
h.app.refreshViewportBreakpoints();
|
||||||
|
// @ts-ignore
|
||||||
|
h.app.refreshEditorBreakpoints();
|
||||||
window.h.app.refresh();
|
window.h.app.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ describe("setActiveTool()", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||||
await render(
|
await render(
|
||||||
<Excalidraw ref={(api) => excalidrawAPIPromise.resolve(api as any)} />,
|
<Excalidraw
|
||||||
|
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
excalidrawAPI = await excalidrawAPIPromise;
|
excalidrawAPI = await excalidrawAPIPromise;
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-18
@@ -23,7 +23,7 @@ import { LinearElementEditor } from "./element/linearElementEditor";
|
|||||||
import { SuggestedBinding } from "./element/binding";
|
import { SuggestedBinding } from "./element/binding";
|
||||||
import { ImportedDataState } from "./data/types";
|
import { ImportedDataState } from "./data/types";
|
||||||
import type App from "./components/App";
|
import type App from "./components/App";
|
||||||
import type { ResolvablePromise, throttleRAF } from "./utils";
|
import type { throttleRAF } from "./utils";
|
||||||
import { Spreadsheet } from "./charts";
|
import { Spreadsheet } from "./charts";
|
||||||
import { Language } from "./i18n";
|
import { Language } from "./i18n";
|
||||||
import { ClipboardData } from "./clipboard";
|
import { ClipboardData } from "./clipboard";
|
||||||
@@ -34,7 +34,7 @@ import type { FileSystemHandle } from "./data/filesystem";
|
|||||||
import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||||
import { ContextMenuItems } from "./components/ContextMenu";
|
import { ContextMenuItems } from "./components/ContextMenu";
|
||||||
import { SnapLine } from "./snapping";
|
import { SnapLine } from "./snapping";
|
||||||
import { Merge, ForwardRef, ValueOf } from "./utility-types";
|
import { Merge, ValueOf } from "./utility-types";
|
||||||
|
|
||||||
export type Point = Readonly<RoughPoint>;
|
export type Point = Readonly<RoughPoint>;
|
||||||
|
|
||||||
@@ -362,15 +362,6 @@ export type LibraryItemsSource =
|
|||||||
| Promise<LibraryItems_anyVersion | Blob>;
|
| Promise<LibraryItems_anyVersion | Blob>;
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// NOTE ready/readyPromise props are optional for host apps' sake (our own
|
|
||||||
// implem guarantees existence)
|
|
||||||
export type ExcalidrawAPIRefValue =
|
|
||||||
| ExcalidrawImperativeAPI
|
|
||||||
| {
|
|
||||||
readyPromise?: ResolvablePromise<ExcalidrawImperativeAPI>;
|
|
||||||
ready?: false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExcalidrawInitialDataState = Merge<
|
export type ExcalidrawInitialDataState = Merge<
|
||||||
ImportedDataState,
|
ImportedDataState,
|
||||||
{
|
{
|
||||||
@@ -390,7 +381,7 @@ export interface ExcalidrawProps {
|
|||||||
| ExcalidrawInitialDataState
|
| ExcalidrawInitialDataState
|
||||||
| null
|
| null
|
||||||
| Promise<ExcalidrawInitialDataState | null>;
|
| Promise<ExcalidrawInitialDataState | null>;
|
||||||
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
|
excalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
|
||||||
isCollaborating?: boolean;
|
isCollaborating?: boolean;
|
||||||
onPointerUpdate?: (payload: {
|
onPointerUpdate?: (payload: {
|
||||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||||
@@ -630,8 +621,6 @@ export type ExcalidrawImperativeAPI = {
|
|||||||
refresh: InstanceType<typeof App>["refresh"];
|
refresh: InstanceType<typeof App>["refresh"];
|
||||||
setToast: InstanceType<typeof App>["setToast"];
|
setToast: InstanceType<typeof App>["setToast"];
|
||||||
addFiles: (data: BinaryFileData[]) => void;
|
addFiles: (data: BinaryFileData[]) => void;
|
||||||
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
|
||||||
ready: true;
|
|
||||||
id: string;
|
id: string;
|
||||||
setActiveTool: InstanceType<typeof App>["setActiveTool"];
|
setActiveTool: InstanceType<typeof App>["setActiveTool"];
|
||||||
setCursor: InstanceType<typeof App>["setCursor"];
|
setCursor: InstanceType<typeof App>["setCursor"];
|
||||||
@@ -667,11 +656,15 @@ export type ExcalidrawImperativeAPI = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Device = Readonly<{
|
export type Device = Readonly<{
|
||||||
isSmScreen: boolean;
|
viewport: {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
isLandscape: boolean;
|
||||||
|
};
|
||||||
|
editor: {
|
||||||
|
isMobile: boolean;
|
||||||
|
canFitSidebar: boolean;
|
||||||
|
};
|
||||||
isTouchScreen: boolean;
|
isTouchScreen: boolean;
|
||||||
canDeviceFitSidebar: boolean;
|
|
||||||
isLandscape: boolean;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type FrameNameBounds = {
|
type FrameNameBounds = {
|
||||||
|
|||||||
+10
-1
@@ -834,11 +834,18 @@ export const isOnlyExportingSingleFrame = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* supply `null` as message if non-never value is valid, you just need to
|
||||||
|
* typecheck against it
|
||||||
|
*/
|
||||||
export const assertNever = (
|
export const assertNever = (
|
||||||
value: never,
|
value: never,
|
||||||
message: string,
|
message: string | null,
|
||||||
softAssert?: boolean,
|
softAssert?: boolean,
|
||||||
): never => {
|
): never => {
|
||||||
|
if (!message) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
if (softAssert) {
|
if (softAssert) {
|
||||||
console.error(message);
|
console.error(message);
|
||||||
return value;
|
return value;
|
||||||
@@ -931,3 +938,5 @@ export const isMemberOf = <T extends string>(
|
|||||||
? collection.includes(value as T)
|
? collection.includes(value as T)
|
||||||
: collection.hasOwnProperty(value);
|
: collection.hasOwnProperty(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||||
|
|||||||
@@ -1602,10 +1602,10 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"
|
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"
|
||||||
integrity sha512-rFIq8+A8WvkEzBsF++Rw6gzxE+hU3ZNkdg8foI+Upz2y/rOC/gUpWJaggPbCkoH3nlREVU59axQjZ1+F6ePRGg==
|
integrity sha512-rFIq8+A8WvkEzBsF++Rw6gzxE+hU3ZNkdg8foI+Upz2y/rOC/gUpWJaggPbCkoH3nlREVU59axQjZ1+F6ePRGg==
|
||||||
|
|
||||||
"@excalidraw/random-username@1.0.0":
|
"@excalidraw/random-username@1.1.0":
|
||||||
version "1.0.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/random-username/-/random-username-1.0.0.tgz#6d5293148aee6cd08dcdfcadc0c91276572f4499"
|
resolved "https://registry.yarnpkg.com/@excalidraw/random-username/-/random-username-1.1.0.tgz#6f388d6a9708cf655b8c9c6aa3fa569ee71ecf0f"
|
||||||
integrity sha512-pd4VapWahQ7PIyThGq32+C+JUS73mf3RSdC7BmQiXzhQsCTU4RHc8y9jBi+pb1CFV0iJXvjJRXnVdLCbTj3+HA==
|
integrity sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==
|
||||||
|
|
||||||
"@firebase/analytics-types@0.4.0":
|
"@firebase/analytics-types@0.4.0":
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user