Compare commits

..

45 Commits

Author SHA1 Message Date
Ryan Di 3c34b3f48a add clippings bounds tolerance, improve group related checks 2023-11-17 17:23:30 +08:00
Ryan Di 683b80ad2b alternative clipping improvement 2023-11-14 18:31:21 +08:00
Ryan Di d636abff79 Merge branch 'master' into frame-group-perf 2023-11-13 18:21:21 +08:00
Ryan Di 47d8fa542c remove redundant code 2023-11-13 18:18:36 +08:00
Ryan Di 34cf71b0f4 keep dynamic clipping with batch clipping 2023-11-13 18:17:39 +08:00
zsviczian ceb255e8ee fix: exportToSvg to honor frameRendering also for name not only for frame itself (#7270)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-12 22:34:05 +00:00
David Luzar ae5b9a4ffd fix: not cloning elements on export polluting Scene mapping (#7276) 2023-11-12 23:32:12 +01:00
zsviczian 3d4ff59f40 fix: Can't toggle penMode off due to missing typecheck in togglePenMode (#7273)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-12 13:24:13 +01:00
David Luzar 7b00089314 chore: bump @excalidraw/random-username (#7272) 2023-11-11 19:23:22 +01:00
zsviczian af6b81df40 fix: Replace hard coded font family with const value in addFrameLabelsAsTextElements (#7269) 2023-11-11 10:04:02 +01:00
FilBot3 02cc8440c4 feat: allow D&D dice app domain for embeds (#7263)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2023-11-10 15:29:19 +00:00
David Luzar 6363492cee fix: perf issue when ungrouping elements within frame (#7265)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2023-11-10 16:13:08 +01:00
Sahil Nagpure 900b317bf3 feat: remove full screen shortcut (#7222) 2023-11-10 14:44:02 +00:00
Gabriel Lalonde 68179356e6 fix: Fixes the shortcut collision between "toggleHandTool" and "distributeHorizontally" (#7189)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-10 15:33:02 +01:00
Jan-Peter Dhallé 3ed15e95da Small typo fix frames.mdx (#7216) 2023-11-10 15:23:43 +01:00
zsviczian 798e1fd858 fix: allow pointer events when editing a linear element (#7238) 2023-11-10 15:21:59 +01:00
David Luzar f66c93633c feat: make adaptive-roughness less aggressive (#7250) 2023-11-10 13:32:34 +01:00
Ryan Di a30e46b756 batch clipping 2023-11-10 19:24:32 +08:00
Aakansha Doshi cee00767df feat: support excalidrawAPI and remove refs support (#7251)
* feat: support excalidrawAPI and remove refs support

* update changelog

* remove ready and readyPromise

* update changelog

* update changelog
2023-11-10 15:33:43 +05:30
Ryan Di 71ba0a3f26 Merge branch 'master' into frame-group-perf 2023-11-10 15:29:46 +08:00
Ryan Di 9bcd0b69dc remove redundant code 2023-11-10 15:16:11 +08:00
David Luzar 864c0b3ea8 feat: render frames on export (#7210) 2023-11-09 17:00:21 +01:00
Aakansha Doshi a9a6f8eafb docs: update the docs with next js dynamic import support (#7252) 2023-11-09 16:03:35 +05:30
Ryan Di afed893419 keep only unique frames when ungrouping 2023-11-08 22:50:54 +08:00
David Luzar 3c96943db3 test: fix mermaid test flake (#7249) 2023-11-07 18:06:15 +01:00
David Luzar 9006caff39 fix: make modal use viewport breakpoints (#7246) 2023-11-07 10:10:12 +01:00
Aakansha Doshi ce7a847668 feat: export getCommonBounds util (#7247)
* feat: export getCommonBounds util

* add pr link

* fix
2023-11-07 14:19:13 +05:30
David Luzar b1037b342d feat: make device breakpoints more specific (#7243) 2023-11-06 16:29:00 +01:00
Dante Calderon 18a7b97515 chore: Fix typo in comment in LocalData file (#7235) 2023-11-04 18:15:09 +01:00
Aakansha Doshi e8def8da8d feat: Support mermaid flowchart and sequence diagrams to excalidraw diagrams 🥳 (#6920)
* feat: integrate mermaidToExcalidraw

* create mermaid to excal dialog

* allow mermaid syntax and export in preview

* fix

* fix webpack config

* fix markdown error by using named export

* center preview

* set elements as selected when inserted onto canvas

* persist mermaid data to storage

* store canvas data in refs

* load mermaid lazily

* tweak design

* compute width, height correctly for arrows

* fix undefined vertex issue

* add mermaid icon in dropdown

* add a note in dialog

* reset preview when error

* show error in preview when error

* show mermaid error messgae react way

* design tweaks

* add example and docs link

* fix

* tweak design to remove scroll bar

* show a spinner unless mermaid loaded

* regenerate ids when needed via programmatic api, this makes sure for mermaid diagrams ids are regenerated

* tweak

* add option to transform viewport to scene coords in transform api

* make opts optional and use 100% zoom when inserting to canvas

* fix arrow bindings in safari and firefox

* fix elements insert position and viewport centering

* fix: Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.

* defer rendering the preview

* tweak text

* fix tests

* remove only

* make design responsive

* fix: show extra tools dropdown in mobile

* fix mobile css

* width auto

* upgrade mermaid-to-excalidraw

* don't pass appState in deps as its not used

* upgrade mermaid-to-excalidraw to fix firefox issue

* use types from mermaid-to-excalidraw

* upgrade mermaid-to-excalidraw

* use stable version of mermaid-to-excalidraw

* upgrade mermaid-to-excalidraw

* fix width of shapes toolbar for smaller screen size and also fix regression of mobile menu

* use i18n

* better api

* enable test coverage in ui

* Add tests

* use common utils to update and get text editor

* updgrade mermaid-to-excalidraw to support sequence diagrams

* fix test

* don't update arrow container height anytime in when redrawing text bounding box

* increase size limit

* increase size limit of vendor to 900kb

* use openDialog for mermaid

* upgrade mermaid-to-excalidraw

* update frame id post generation

* upgrade mermaid-to-excalidraw to add entity codes support

* update size limit

* upgrade mermaid-to-excalidraw package with frame api changes

* upgrade mermaid-to-excalidraw to remove directive and use config

* don't highlight mermaid tool and remove unused api setSelection

* stop using loading state to update text area

* move some styling to scss

* review fixes

* use modifiedTableIcon props and remove stale snap

* css

* dialog css

* fix snap

* use dialog border

* change mermaidToExcalidrawLib to state

* better styling of errors

* make modal bigger

* fix mobile

* update snaps

* fix icon color

* fix dark mode insert button color

* horizontally center spinner

* render canvas conditionally on loaded state

* rd tweaks

* tweak class names

* remove max height

* typo in example

* upgrade mermaid-to-excalidraw

* simplify error state

* fix height & overflow on vertical breakpoint

* fix lint

* show errors in overlay

* set textarea font family

* reduce opacity

* update snap

* upgrade to mermaid  0.1.2

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-11-03 17:41:34 +05:30
David Luzar a7db41c5ba fix: align input :hover/:focus with spec (#7225) 2023-11-02 16:06:26 +01:00
David Luzar d8166d9e1d fix: dialog remounting on className updates (#7224) 2023-11-02 16:06:15 +01:00
Farzaneh Sefidabi 81c0259041 docs: add npm downloads rate badge to README.md file (#7127) 2023-11-01 13:27:57 +01:00
Aakansha Doshi f5c91c3a0f feat: support frames via programmatic API (#7205)
* update frame id post generation

* support frames via programmatic API

* fix types

* add test for frames

* throw error when element doesn't exist

* naming tweaks

* update the api to use children

* consider max of frame dimensions and calculated bounds of elements

* consider bound elements in frame api
2023-11-01 17:14:04 +05:30
David Luzar 9b8de8a12e test: disable flaky test (#7213) 2023-10-31 12:05:08 +01:00
David Luzar ea677d4581 feat: make clipboard more robust and reintroduce contextmenu actions (#7198) 2023-10-28 19:29:28 +00:00
Aakansha Doshi ec2de7205f fix: don't update label position when dragging labelled arrows (#6891)
* fix: don't update label position when dragging labelled arrows

* lint

* add test

* don't update coords for label when labelled arrow inside frame

* increase locales bundle size limit
2023-10-27 12:06:11 +05:30
Are d5e3f436dc feat: add approximate elements in bbox detection (#6727)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-26 23:33:00 +02:00
Aakansha Doshi dcf4592e79 feat: regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping (#7195)
* feat: regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping

* type

* increase limit as some past PR(s) increased the bundle size

* review fixes

* update changelog
2023-10-27 00:43:48 +05:30
David Luzar d1f8eec174 feat: support giphy.com embed domain (#7192) 2023-10-26 00:00:50 +02:00
David Luzar 0f81c30276 fix: frame add/remove/z-index ordering changes (#7194) 2023-10-25 23:16:02 +02:00
zsviczian f098789d16 fix: element relative position when dragging multiple elements on grid (#7107)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-25 22:48:03 +02:00
David Luzar f794b0bb90 fix: freedraw non-solid bg hitbox not working (#7193) 2023-10-25 17:21:01 +02:00
David Luzar 104f64f1dc revert: remove bound-arrows from frames (#7190) 2023-10-25 10:39:19 +02:00
Viczián András 71ad3c5356 fix: Actions panel ux improvement (#6850)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-24 18:36:13 +00:00
131 changed files with 5673 additions and 2227 deletions
+3
View File
@@ -25,6 +25,9 @@
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE"> <a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /> <img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a> </a>
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
</a>
<a href="https://docs.excalidraw.com/docs/introduction/contributing"> <a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /> <img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
</a> </a>
@@ -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
+1 -11
View File
@@ -19,14 +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.
# Arrows
An arrow can be a child of a frame only if it has no binding (either start or end) to any other element, regardless of whether the bound element is inside the frame or not.
This ensures that when an arrow is bound to an element outside the frame, it's rendered and behaves correctly.
Therefore, when an arrow (that's a child of a frame) gets bound to an element, it's automatically removed from the frame.
Bound-arrow is duplicated alongside a frame only if the arrow start is bound to an element within that frame.
+1 -1
View File
@@ -6,7 +6,7 @@
* *
* - DataState refers to full state of the app: appState, elements, images, * - DataState refers to full state of the app: appState, elements, images,
* though some state is saved separately (collab username, library) for one * though some state is saved separately (collab username, library) for one
* reason or another. We also save different data to different sotrage * reason or another. We also save different data to different storage
* (localStorage, indexedDB). * (localStorage, indexedDB).
*/ */
+1 -1
View File
@@ -131,5 +131,5 @@ export class Debug {
}; };
}; };
} }
//@ts-ignore
window.debug = Debug; window.debug = Debug;
+1 -1
View File
@@ -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}
+12 -6
View File
@@ -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,
},
} }
`); `);
}); });
+5 -6
View File
@@ -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,
), ),
), ),
+4 -3
View File
@@ -21,7 +21,8 @@
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.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",
@@ -53,7 +54,7 @@
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"roughjs": "4.6.5", "roughjs": "4.6.4",
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"tunnel-rat": "0.1.2" "tunnel-rat": "0.1.2"
@@ -125,7 +126,7 @@
"test": "yarn test:app", "test": "yarn test:app",
"test:coverage": "vitest --coverage", "test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch", "test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui", "test:ui": "yarn test --ui --coverage.enabled=true",
"autorelease": "node scripts/autorelease.js", "autorelease": "node scripts/autorelease.js",
"prerelease": "node scripts/prerelease.js", "prerelease": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000", "build:preview": "yarn build && vite preview --port 5000",
+2 -1
View File
@@ -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,
}); });
+86 -35
View File
@@ -3,33 +3,43 @@ import { register } from "./register";
import { import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
copyToClipboard, copyToClipboard,
createPasteEvent,
probablySupportsClipboardBlob, probablySupportsClipboardBlob,
probablySupportsClipboardWriteText, probablySupportsClipboardWriteText,
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";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: async (elements, appState, event: ClipboardEvent | null, app) => {
const elementsToCopy = app.scene.getSelectedElements({ const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true, includeElementsInFrames: true,
}); });
copyToClipboard(elementsToCopy, app.files); try {
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
}
return { return {
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy", contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
@@ -38,15 +48,55 @@ export const actionCopy = register({
export const actionPaste = register({ export const actionPaste = register({
name: "paste", name: "paste",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => { perform: async (elements, appState, data, app) => {
app.pasteFromClipboard(null); let types;
try {
types = await readSystemClipboard();
} catch (error: any) {
if (error.name === "AbortError" || error.name === "NotAllowedError") {
// user probably aborted the action. Though not 100% sure, it's best
// to not annoy them with an error message.
return false;
}
console.error(`actionPaste ${error.name}: ${error.message}`);
if (isFirefox) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
},
};
}
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
},
};
}
try {
app.pasteFromClipboard(createPasteEvent({ types }));
} catch (error: any) {
console.error(error);
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
},
};
}
return { return {
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste", contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
@@ -55,13 +105,10 @@ export const actionPaste = register({
export const actionCut = register({ export const actionCut = register({
name: "cut", name: "cut",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, data, app) => { perform: (elements, appState, event: ClipboardEvent | null, app) => {
actionCopy.perform(elements, appState, data, app); actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState);
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut", contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
}); });
@@ -75,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,
@@ -124,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,
+3 -10
View File
@@ -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,12 +155,7 @@ const duplicateElements = (
groupId, groupId,
).flatMap((element) => ).flatMap((element) =>
isFrameElement(element) isFrameElement(element)
? [ ? [...getFrameChildren(elements, element.id), element]
...getFrameElements(elements, element.id, {
includeBoundArrows: true,
}),
element,
]
: [element], : [element],
); );
@@ -186,9 +181,7 @@ const duplicateElements = (
continue; continue;
} }
if (isElementAFrame) { if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id, { const elementsInFrame = getFrameChildren(sortedElements, element.id);
includeBoundArrows: true,
});
elementsWithClones.push( elementsWithClones.push(
...markAsProcessed([ ...markAsProcessed([
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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
View File
@@ -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,
-18
View File
@@ -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,
+167
View File
@@ -0,0 +1,167 @@
import { Excalidraw } from "../packages/excalidraw/index";
import { queryByTestId } from "@testing-library/react";
import { render } from "../tests/test-utils";
import { UI } from "../tests/helpers/ui";
import { API } from "../tests/helpers/api";
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
const { h } = window;
describe("element locking", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("properties when tool selected", () => {
it("should show active background top picks", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
currentItemBackgroundColor: color,
});
const activeColor = queryByTestId(
document.body,
`color-top-pick-${color}`,
);
expect(activeColor).toHaveClass("active");
});
it("should show fill style when background non-transparent", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
currentItemBackgroundColor: color,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active");
h.setState({
currentItemFillStyle: "solid",
});
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
expect(solidFillStyle).toHaveClass("active");
});
it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle");
h.setState({
currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toBe(null);
});
it("should show horizontal text align for text tool", () => {
UI.clickTool("text");
h.setState({
currentItemTextAlign: "right",
});
const centerTextAlign = queryByTestId(document.body, `align-right`);
expect(centerTextAlign).toBeChecked();
});
});
describe("properties when elements selected", () => {
it("should show active styles when single element selected", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: "red",
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toHaveClass("active");
});
it("should not show fill style selected element's background is transparent", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toBe(null);
});
it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-thin`,
);
expect(thinStrokeWidthButton).toBeChecked();
});
it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
expect(
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked();
});
it("should show properties of different element types when selected", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
const text = API.createElement({
type: "text",
fontFamily: FONT_FAMILY.Cascadia,
});
h.elements = [rect, text];
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
});
});
});
+85 -28
View File
@@ -1,4 +1,4 @@
import { AppState } from "../../src/types"; import { AppState, Primitive } from "../../src/types";
import { import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -51,6 +51,7 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
STROKE_WIDTH,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { import {
@@ -82,7 +83,6 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { import {
canChangeRoundness,
canHaveArrowheads, canHaveArrowheads,
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
getSelectedElements, getSelectedElements,
@@ -118,25 +118,44 @@ export const changeProperty = (
}); });
}; };
export const getFormValue = function <T>( export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
defaultValue: T, isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingElement = appState.editingElement; const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements); const nonDeletedElements = getNonDeletedElements(elements);
return (
(editingElement && getAttribute(editingElement)) ?? let ret: T | null = null;
(isSomeElementSelected(nonDeletedElements, appState)
? getCommonAttributeOfSelectedElements( if (editingElement) {
nonDeletedElements, ret = getAttribute(editingElement);
}
if (!ret) {
const hasSelection = isSomeElementSelected(nonDeletedElements, appState);
if (hasSelection) {
ret =
getCommonAttributeOfSelectedElements(
isRelevantElement === true
? nonDeletedElements
: nonDeletedElements.filter((el) => isRelevantElement(el)),
appState, appState,
getAttribute, getAttribute,
) ) ??
: defaultValue) ?? (typeof defaultValue === "function"
defaultValue ? defaultValue(true)
); : defaultValue);
} else {
ret =
typeof defaultValue === "function" ? defaultValue(false) : defaultValue;
}
}
return ret;
}; };
const offsetElementAfterFontResize = ( const offsetElementAfterFontResize = (
@@ -247,6 +266,7 @@ export const actionChangeStrokeColor = register({
elements, elements,
appState, appState,
(element) => element.strokeColor, (element) => element.strokeColor,
true,
appState.currentItemStrokeColor, appState.currentItemStrokeColor,
)} )}
onChange={(color) => updateData({ currentItemStrokeColor: color })} onChange={(color) => updateData({ currentItemStrokeColor: color })}
@@ -289,6 +309,7 @@ export const actionChangeBackgroundColor = register({
elements, elements,
appState, appState,
(element) => element.backgroundColor, (element) => element.backgroundColor,
true,
appState.currentItemBackgroundColor, appState.currentItemBackgroundColor,
)} )}
onChange={(color) => updateData({ currentItemBackgroundColor: color })} onChange={(color) => updateData({ currentItemBackgroundColor: color })}
@@ -307,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) =>
@@ -338,23 +359,28 @@ export const actionChangeFillStyle = register({
} (${getShortcutKey("Alt-Click")})`, } (${getShortcutKey("Alt-Click")})`,
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon, icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
active: allElementsZigZag ? true : undefined, active: allElementsZigZag ? true : undefined,
testId: `fill-hachure`,
}, },
{ {
value: "cross-hatch", value: "cross-hatch",
text: t("labels.crossHatch"), text: t("labels.crossHatch"),
icon: FillCrossHatchIcon, icon: FillCrossHatchIcon,
testId: `fill-cross-hatch`,
}, },
{ {
value: "solid", value: "solid",
text: t("labels.solid"), text: t("labels.solid"),
icon: FillSolidIcon, icon: FillSolidIcon,
testId: `fill-solid`,
}, },
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => element.fillStyle, (element) => element.fillStyle,
appState.currentItemFillStyle, (element) => element.hasOwnProperty("fillStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemFillStyle,
)} )}
onClick={(value, event) => { onClick={(value, event) => {
const nextValue = const nextValue =
@@ -393,26 +419,31 @@ export const actionChangeStrokeWidth = register({
group="stroke-width" group="stroke-width"
options={[ options={[
{ {
value: 1, value: STROKE_WIDTH.thin,
text: t("labels.thin"), text: t("labels.thin"),
icon: StrokeWidthBaseIcon, icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
}, },
{ {
value: 2, value: STROKE_WIDTH.bold,
text: t("labels.bold"), text: t("labels.bold"),
icon: StrokeWidthBoldIcon, icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
}, },
{ {
value: 4, value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"), text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon, icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
}, },
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => element.strokeWidth, (element) => element.strokeWidth,
appState.currentItemStrokeWidth, (element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -461,7 +492,9 @@ export const actionChangeSloppiness = register({
elements, elements,
appState, appState,
(element) => element.roughness, (element) => element.roughness,
appState.currentItemRoughness, (element) => element.hasOwnProperty("roughness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoughness,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -509,7 +542,9 @@ export const actionChangeStrokeStyle = register({
elements, elements,
appState, appState,
(element) => element.strokeStyle, (element) => element.strokeStyle,
appState.currentItemStrokeStyle, (element) => element.hasOwnProperty("strokeStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeStyle,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -549,6 +584,7 @@ export const actionChangeOpacity = register({
elements, elements,
appState, appState,
(element) => element.opacity, (element) => element.opacity,
true,
appState.currentItemOpacity, appState.currentItemOpacity,
) ?? undefined ) ?? undefined
} }
@@ -607,7 +643,12 @@ export const actionChangeFontSize = register({
} }
return null; return null;
}, },
appState.currentItemFontSize || DEFAULT_FONT_SIZE, (element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -692,21 +733,25 @@ export const actionChangeFontFamily = register({
value: FontFamilyValues; value: FontFamilyValues;
text: string; text: string;
icon: JSX.Element; icon: JSX.Element;
testId: string;
}[] = [ }[] = [
{ {
value: FONT_FAMILY.Virgil, value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
icon: FreedrawIcon, icon: FreedrawIcon,
testId: "font-family-virgil",
}, },
{ {
value: FONT_FAMILY.Helvetica, value: FONT_FAMILY.Helvetica,
text: t("labels.normal"), text: t("labels.normal"),
icon: FontFamilyNormalIcon, icon: FontFamilyNormalIcon,
testId: "font-family-normal",
}, },
{ {
value: FONT_FAMILY.Cascadia, value: FONT_FAMILY.Cascadia,
text: t("labels.code"), text: t("labels.code"),
icon: FontFamilyCodeIcon, icon: FontFamilyCodeIcon,
testId: "font-family-code",
}, },
]; ];
@@ -729,7 +774,12 @@ export const actionChangeFontFamily = register({
} }
return null; return null;
}, },
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY, (element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -806,7 +856,10 @@ export const actionChangeTextAlign = register({
} }
return null; return null;
}, },
appState.currentItemTextAlign, (element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -882,7 +935,9 @@ export const actionChangeVerticalAlign = register({
} }
return null; return null;
}, },
VERTICAL_ALIGN.MIDDLE, (element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -947,9 +1002,9 @@ export const actionChangeRoundness = register({
appState, appState,
(element) => (element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) && (element) => element.hasOwnProperty("roundness"),
appState.currentItemRoundness) || (hasSelection) =>
null, hasSelection ? null : appState.currentItemRoundness,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -1043,6 +1098,7 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead ? element.startArrowhead
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true,
appState.currentItemStartArrowhead, appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
@@ -1089,6 +1145,7 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead ? element.endArrowhead
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true,
appState.currentItemEndArrowhead, appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
+13 -5
View File
@@ -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,
-1
View File
@@ -44,7 +44,6 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export { export {
actionToggleCanvasMenu, actionToggleCanvasMenu,
actionToggleEditMenu, actionToggleEditMenu,
actionFullScreen,
actionShortcuts, actionShortcuts,
} from "./actionMenu"; } from "./actionMenu";
+4 -4
View File
@@ -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"})`,
); );
} }
} }
@@ -119,10 +119,10 @@ export class ActionManager {
return true; return true;
} }
executeAction( executeAction<T extends Action>(
action: Action, action: T,
source: ActionSource = "api", source: ActionSource = "api",
value: any = null, value: Parameters<T["perform"]>[2] = null,
) { ) {
const elements = this.getElementsIncludingDeleted(); const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
+184 -10
View File
@@ -1,22 +1,196 @@
import { parseClipboard } from "./clipboard"; import {
import { createPasteEvent } from "./tests/test-utils"; createPasteEvent,
parseClipboard,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
describe("Test parseClipboard", () => { describe("parseClipboard()", () => {
it("should parse valid json correctly", async () => { it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
let text = "123"; let text;
let clipboardData;
// -------------------------------------------------------------------------
let clipboardData = await parseClipboard( text = "123";
createPasteEvent({ "text/plain": text }), clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = "[123]"; text = "[123]";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ "text/plain": text }), createPasteEvent({ types: { "text/plain": text } }),
); );
expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
}); });
it("should parse valid excalidraw JSON if inside text/plain", async () => {
const rect = API.createElement({ type: "rectangle" });
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
});
it("should parse valid excalidraw JSON if inside text/html", async () => {
const rect = API.createElement({ type: "rectangle" });
let json;
let clipboardData;
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
});
it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "imageUrl",
value: "https://example.com/image2.png",
},
]);
});
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "text",
// trimmed
value: "hello",
},
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "text",
value: "my friend!",
},
]);
});
it("should parse spreadsheet from either text/plain and text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
});
}); });
+205 -94
View File
@@ -3,14 +3,18 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { BinaryFiles } from "./types"; import { BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import {
ALLOWED_PASTE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement"; import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame"; import { getContainingFrame } from "./frame";
import { isPromiseLike, isTestEnv } from "./utils"; import { isMemberOf, isPromiseLike } from "./utils";
import { t } from "./i18n";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -30,8 +34,11 @@ export interface ClipboardData {
programmaticAPI?: boolean; programmaticAPI?: boolean;
} }
let CLIPBOARD = ""; type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
let PREFER_APP_CLIPBOARD = false;
type ParsedClipboardEvent =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
export const probablySupportsClipboardReadText = export const probablySupportsClipboardReadText =
"clipboard" in navigator && "readText" in navigator.clipboard; "clipboard" in navigator && "readText" in navigator.clipboard;
@@ -61,10 +68,61 @@ const clipboardContainsElements = (
return false; return false;
}; };
export const copyToClipboard = async ( export const createPasteEvent = ({
elements: readonly NonDeletedExcalidrawElement[], types,
files: BinaryFiles | null, files,
) => { }: {
types?: { [key in AllowedPasteMimeTypes]?: string };
files?: File[];
}) => {
if (!types && !files) {
console.warn("createPasteEvent: no types or files provided");
}
const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(),
});
if (types) {
for (const [type, value] of Object.entries(types)) {
try {
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`);
}
} catch (error: any) {
throw new Error(error.message);
}
}
}
if (files) {
let idx = -1;
for (const file of files) {
idx++;
try {
event.clipboardData?.items.add(file);
if (event.clipboardData?.files[idx] !== file) {
throw new Error(
`Failed to set file "${file.name}" as clipboardData item`,
);
}
} catch (error: any) {
throw new Error(error.message);
}
}
}
return event;
};
export const serializeAsClipboardJSON = ({
elements,
files,
}: {
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}) => {
const framesToCopy = new Set( const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"), elements.filter((element) => element.type === "frame"),
); );
@@ -86,7 +144,7 @@ export const copyToClipboard = async (
); );
} }
// select binded text elements when copying // select bound text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => { elements: elements.map((element) => {
@@ -105,34 +163,20 @@ export const copyToClipboard = async (
}), }),
files: files ? _files : undefined, files: files ? _files : undefined,
}; };
const json = JSON.stringify(contents);
if (isTestEnv()) { return JSON.stringify(contents);
return json;
}
CLIPBOARD = json;
try {
PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json);
} catch (error: any) {
PREFER_APP_CLIPBOARD = true;
console.error(error);
}
}; };
const getAppClipboard = (): Partial<ElementsClipboard> => { export const copyToClipboard = async (
if (!CLIPBOARD) { elements: readonly NonDeletedExcalidrawElement[],
return {}; files: BinaryFiles | null,
} /** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null,
try { ) => {
return JSON.parse(CLIPBOARD); await copyTextToSystemClipboard(
} catch (error: any) { serializeAsClipboardJSON({ elements, files }),
console.error(error); clipboardEvent,
return {}; );
}
}; };
const parsePotentialSpreadsheet = ( const parsePotentialSpreadsheet = (
@@ -166,7 +210,9 @@ function parseHTMLTree(el: ChildNode) {
return result; return result;
} }
const maybeParseHTMLPaste = (event: ClipboardEvent) => { const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html"); const html = event.clipboardData?.getData("text/html");
if (!html) { if (!html) {
@@ -179,7 +225,7 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => {
const content = parseHTMLTree(doc.body); const content = parseHTMLTree(doc.body);
if (content.length) { if (content.length) {
return content; return { type: "mixedContent", value: content };
} }
} catch (error: any) { } catch (error: any) {
console.error(`error in parseHTMLFromPaste: ${error.message}`); console.error(`error in parseHTMLFromPaste: ${error.message}`);
@@ -188,27 +234,88 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => {
return null; return null;
}; };
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
try {
if (navigator.clipboard?.readText) {
return { "text/plain": await navigator.clipboard?.readText() };
}
} catch (error: any) {
// @ts-ignore
if (navigator.clipboard?.read) {
console.warn(
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
);
} else {
throw error;
}
}
let clipboardItems: ClipboardItems;
try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
for (const item of clipboardItems) {
for (const type of item.types) {
if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
} catch (error: any) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
}
if (Object.keys(types).length === 0) {
console.warn("No clipboard data found from clipboard.read().");
return types;
}
return types;
};
/** /**
* Retrieves content from system clipboard (either from ClipboardEvent or * Parses "paste" ClipboardEvent.
* via async clipboard API if supported)
*/ */
const getSystemClipboard = async ( const parseClipboardEvent = async (
event: ClipboardEvent | null, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise< ): Promise<ParsedClipboardEvent> => {
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent }
> => {
try { try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
if (mixedContent) { if (mixedContent) {
return { type: "mixedContent", value: mixedContent }; if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
mixedContent.value
.map((item) => item.value)
.join("\n")
.trim(),
};
}
return mixedContent;
} }
const text = event const text = event.clipboardData?.getData("text/plain");
? event.clipboardData?.getData("text/plain")
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
return { type: "text", value: (text || "").trim() }; return { type: "text", value: (text || "").trim() };
} catch { } catch {
@@ -220,40 +327,32 @@ const getSystemClipboard = async (
* Attempts to parse clipboard. Prefers system clipboard. * Attempts to parse clipboard. Prefers system clipboard.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event, isPlainPaste); const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
if (systemClipboard.type === "mixedContent") { if (parsedEventData.type === "mixedContent") {
return { return {
mixedContent: systemClipboard.value, mixedContent: parsedEventData.value,
}; };
} }
// if system clipboard empty, couldn't be resolved, or contains previously try {
// copied excalidraw scene as SVG, fall back to previously copied excalidraw // if system clipboard contains spreadsheet, use it even though it's
// elements // technically possible it's staler than in-app clipboard
if ( const spreadsheetResult =
!systemClipboard || !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
(!isPlainPaste && systemClipboard.value.includes(SVG_EXPORT_TAG))
) { if (spreadsheetResult) {
return getAppClipboard(); return spreadsheetResult;
}
} catch (error: any) {
console.error(error);
} }
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard.value);
if (spreadsheetResult) {
return spreadsheetResult;
}
const appClipboardData = getAppClipboard();
try { try {
const systemClipboardData = JSON.parse(systemClipboard.value); const systemClipboardData = JSON.parse(parsedEventData.value);
const programmaticAPI = const programmaticAPI =
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI; systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
if (clipboardContainsElements(systemClipboardData)) { if (clipboardContainsElements(systemClipboardData)) {
@@ -266,18 +365,9 @@ export const parseClipboard = async (
programmaticAPI, programmaticAPI,
}; };
} }
} catch (e) {} } catch {}
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't return { text: parsedEventData.value };
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? {
...appClipboardData,
text: isPlainPaste
? JSON.stringify(appClipboardData.elements, null, 2)
: undefined,
}
: { text: systemClipboard.value };
}; };
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => { export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
@@ -310,28 +400,49 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
} }
}; };
export const copyTextToSystemClipboard = async (text: string | null) => { export const copyTextToSystemClipboard = async (
let copied = false; text: string | null,
clipboardEvent?: ClipboardEvent | null,
) => {
// (1) first try using Async Clipboard API
if (probablySupportsClipboardWriteText) { if (probablySupportsClipboardWriteText) {
try { try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document // NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused // not focused
await navigator.clipboard.writeText(text || ""); await navigator.clipboard.writeText(text || "");
copied = true; return;
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
} }
} }
// Note that execCommand doesn't allow copying empty strings, so if we're // (2) if fails and we have access to ClipboardEvent, use plain old setData()
// clearing clipboard using this API, we must copy at least an empty char try {
if (!copied && !copyTextViaExecCommand(text || " ")) { if (clipboardEvent) {
throw new Error("couldn't copy"); clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
throw new Error("Failed to setData on clipboardEvent");
}
return;
}
} catch (error: any) {
console.error(error);
}
// (3) if that fails, use document.execCommand
if (!copyTextViaExecCommand(text)) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
} }
}; };
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48 // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
const copyTextViaExecCommand = (text: string) => { const copyTextViaExecCommand = (text: string | null) => {
// execCommand doesn't allow copying empty strings, so if we're
// clearing clipboard using this API, we must copy at least an empty char
if (!text) {
text = " ";
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const textarea = document.createElement("textarea"); const textarea = document.createElement("textarea");
+63 -110
View File
@@ -11,7 +11,6 @@ import {
hasBackground, hasBackground,
hasStrokeStyle, hasStrokeStyle,
hasStrokeWidth, hasStrokeWidth,
hasText,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { AppClassProperties, UIAppState, Zoom } from "../types"; import { AppClassProperties, UIAppState, Zoom } from "../types";
@@ -20,7 +19,7 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks"; import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { actionToggleZenMode } from "../actions"; import { actionToggleZenMode } from "../actions";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
@@ -35,6 +34,7 @@ import {
EmbedIcon, EmbedIcon,
extraToolsIcon, extraToolsIcon,
frameToolIcon, frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon, laserPointerToolIcon,
} from "./icons"; } from "./icons";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -66,7 +66,8 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
hasBackground(appState.activeTool.type) || (hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some( targetElements.some(
(element) => (element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
@@ -123,14 +124,15 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</> <>{renderAction("changeRoundness")}</>
)} )}
{(hasText(appState.activeTool.type) || {(appState.activeTool.type === "text" ||
targetElements.some((element) => hasText(element.type))) && ( targetElements.some(isTextElement)) && (
<> <>
{renderAction("changeFontSize")} {renderAction("changeFontSize")}
{renderAction("changeFontFamily")} {renderAction("changeFontFamily")}
{suppportsHorizontalAlign(targetElements) && {(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements)) &&
renderAction("changeTextAlign")} renderAction("changeTextAlign")}
</> </>
)} )}
@@ -200,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")}
@@ -222,7 +224,6 @@ export const ShapesSwitcher = ({
app: AppClassProperties; app: AppClassProperties;
}) => { }) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
const frameToolSelected = activeTool.type === "frame"; const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser"; const laserToolSelected = activeTool.type === "laser";
@@ -272,111 +273,63 @@ export const ShapesSwitcher = ({
); );
})} })}
<div className="App-toolbar__divider" /> <div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? ( <DropdownMenu open={isExtraToolsMenuOpen}>
<> <DropdownMenu.Trigger
<ToolButton className={clsx("App-toolbar__extra-tools-trigger", {
className={clsx("Shape", { fillable: false })} "App-toolbar__extra-tools-trigger--selected":
type="radio" frameToolSelected ||
embeddableToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })}
icon={frameToolIcon} icon={frameToolIcon}
checked={activeTool.type === "frame"} shortcut={KEYS.F.toLocaleUpperCase()}
name="editor-current-shape" data-testid="toolbar-frame"
title={`${capitalizeString( selected={frameToolSelected}
t("toolBar.frame"), >
)}${KEYS.F.toLocaleUpperCase()}`} {t("toolBar.frame")}
keyBindingLabel={KEYS.F.toLocaleUpperCase()} </DropdownMenu.Item>
aria-label={capitalizeString(t("toolBar.frame"))} <DropdownMenu.Item
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()} onSelect={() => app.setActiveTool({ type: "embeddable" })}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
app.setActiveTool({ type: "frame" });
}}
selected={activeTool.type === "frame"}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
icon={EmbedIcon} icon={EmbedIcon}
checked={activeTool.type === "embeddable"} data-testid="toolbar-embeddable"
name="editor-current-shape" selected={embeddableToolSelected}
title={capitalizeString(t("toolBar.embeddable"))}
aria-label={capitalizeString(t("toolBar.embeddable"))}
data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
app.setActiveTool({ type: "embeddable" });
}}
selected={activeTool.type === "embeddable"}
/>
</>
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className={clsx("App-toolbar__extra-tools-trigger", {
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
> >
{extraToolsIcon} {t("toolBar.embeddable")}
</DropdownMenu.Trigger> </DropdownMenu.Item>
<DropdownMenu.Content <DropdownMenu.Item
onClickOutside={() => setIsExtraToolsMenuOpen(false)} onSelect={() => app.setActiveTool({ type: "laser" })}
onSelect={() => setIsExtraToolsMenuOpen(false)} icon={laserPointerToolIcon}
className="App-toolbar__extra-tools-dropdown" data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
> >
<DropdownMenu.Item {t("toolBar.laser")}
onSelect={() => { </DropdownMenu.Item>
app.setActiveTool({ type: "frame" }); <DropdownMenu.Item
}} onSelect={() => app.setOpenDialog("mermaid")}
icon={frameToolIcon} icon={mermaidLogoIcon}
shortcut={KEYS.F.toLocaleUpperCase()} data-testid="toolbar-embeddable"
data-testid="toolbar-frame" >
selected={frameToolSelected} {t("toolBar.mermaidToExcalidraw")}
> </DropdownMenu.Item>
{t("toolBar.frame")} </DropdownMenu.Content>
</DropdownMenu.Item> </DropdownMenu>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "embeddable" });
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "laser" });
}}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
{t("toolBar.laser")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</> </>
); );
}; };
+177 -156
View File
@@ -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,
@@ -366,6 +364,7 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer"; import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import { LaserToolOverlay } from "./LaserTool/LaserTool"; import { LaserToolOverlay } from "./LaserTool/LaserTool";
import { LaserPathManager } from "./LaserTool/LaserPathManager"; import { LaserPathManager } from "./LaserTool/LaserPathManager";
import { import {
@@ -380,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";
@@ -435,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;
@@ -471,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>();
@@ -534,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,
@@ -565,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,
@@ -597,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 = {
@@ -1042,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 = () => {
@@ -1092,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"
@@ -1120,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",
@@ -1175,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}
@@ -1245,7 +1248,11 @@ class App extends React.Component<AppProps, AppState> {
isCollaborating={this.props.isCollaborating} isCollaborating={this.props.isCollaborating}
> >
{this.props.children} {this.props.children}
{this.state.openDialog === "mermaid" && (
<MermaidToExcalidraw />
)}
</LayerUI> </LayerUI>
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" /> <div className="excalidraw-eye-dropper-container" />
@@ -1275,6 +1282,12 @@ class App extends React.Component<AppProps, AppState> {
top={this.state.contextMenu.top} top={this.state.contextMenu.top}
left={this.state.contextMenu.left} left={this.state.contextMenu.left}
actionManager={this.actionManager} actionManager={this.actionManager}
onClose={(callback) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
callback?.();
});
}}
/> />
)} )}
<StaticCanvas <StaticCanvas
@@ -1354,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(
@@ -1366,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)
@@ -1646,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() {
@@ -1701,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));
@@ -1791,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({});
}); });
@@ -1844,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);
} }
@@ -1929,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 (
@@ -2110,7 +2139,7 @@ class App extends React.Component<AppProps, AppState> {
if (!isExcalidrawActive || isWritableElement(event.target)) { if (!isExcalidrawActive || isWritableElement(event.target)) {
return; return;
} }
this.cutAll(); this.actionManager.executeAction(actionCut, "keyboard", event);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}); });
@@ -2122,19 +2151,11 @@ class App extends React.Component<AppProps, AppState> {
if (!isExcalidrawActive || isWritableElement(event.target)) { if (!isExcalidrawActive || isWritableElement(event.target)) {
return; return;
} }
this.copyAll(); this.actionManager.executeAction(actionCopy, "keyboard", event);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}); });
private cutAll = () => {
this.actionManager.executeAction(actionCut, "keyboard");
};
private copyAll = () => {
this.actionManager.executeAction(actionCopy, "keyboard");
};
private static resetTapTwice() { private static resetTapTwice() {
didTapTwice = false; didTapTwice = false;
} }
@@ -2195,8 +2216,8 @@ class App extends React.Component<AppProps, AppState> {
}; };
public pasteFromClipboard = withBatchedUpdates( public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent | null) => { async (event: ClipboardEvent) => {
const isPlainPaste = !!(IS_PLAIN_PASTE && event); const isPlainPaste = !!IS_PLAIN_PASTE;
// #686 // #686
const target = document.activeElement; const target = document.activeElement;
@@ -2326,11 +2347,12 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
private addElementsFromPasteOrLibrary = (opts: { addElementsFromPasteOrLibrary = (opts: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
files: BinaryFiles | null; files: BinaryFiles | null;
position: { clientX: number; clientY: number } | "cursor" | "center"; position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null, undefined); const elements = restoreElements(opts.elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -2406,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,
@@ -2435,6 +2457,12 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: "selection" });
if (opts.fitToContent) {
this.scrollToContent(newElements, {
fitToContent: true,
});
}
}; };
// TODO rewrite this to paste both text & images at the same time if // TODO rewrite this to paste both text & images at the same time if
@@ -2554,12 +2582,18 @@ class App extends React.Component<AppProps, AppState> {
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) { if (text.length) {
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x,
y: currentY,
});
const element = newTextElement({ const element = newTextElement({
...textElementProps, ...textElementProps,
x, x,
y: currentY, y: currentY,
text, text,
lineHeight, lineHeight,
frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
acc.push(element); acc.push(element);
currentY += element.height + LINE_GAP; currentY += element.height + LINE_GAP;
@@ -2608,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", {
@@ -2642,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) => {
@@ -2682,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,
@@ -3137,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 });
@@ -3304,6 +3340,10 @@ class App extends React.Component<AppProps, AppState> {
}); });
}; };
setOpenDialog = (dialogType: AppState["openDialog"]) => {
this.setState({ openDialog: dialogType });
};
private setCursor = (cursor: string) => { private setCursor = (cursor: string) => {
setCursor(this.interactiveCanvas, cursor); setCursor(this.interactiveCanvas, cursor);
}; };
@@ -3574,11 +3614,6 @@ class App extends React.Component<AppProps, AppState> {
return getElementsAtPosition(elements, (element) => return getElementsAtPosition(elements, (element) =>
hitTest(element, this.state, this.frameNameBoundsCache, x, y), hitTest(element, this.state, this.frameNameBoundsCache, x, y),
).filter((element) => { ).filter((element) => {
// arrows don't clip even if they're children of frames,
// so always allow hitbox regardless of beinging contained in frame
if (isArrowElement(element)) {
return true;
}
// hitting a frame's element from outside the frame is not considered a hit // hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element); const containingFrame = getContainingFrame(element);
return containingFrame && return containingFrame &&
@@ -3750,32 +3785,9 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (!event[KEYS.CTRL_OR_CMD]) {
// If double clicked without any ctrl/cmd modifier on top of a point,
// toggle split mode for that point. Else, treat as regular double click.
const pointUnderCursorIndex =
LinearElementEditor.getPointIndexUnderCursor(
selectedElements[0],
this.state.zoom,
sceneX,
sceneY,
);
if (pointUnderCursorIndex >= 0) {
LinearElementEditor.toggleSegmentSplitAtIndex(
selectedElements[0],
pointUnderCursorIndex,
);
return;
}
}
if ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(!this.state.editingLinearElement || (!this.state.editingLinearElement ||
@@ -3799,6 +3811,11 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
const selectedGroupIds = getSelectedGroupIds(this.state); const selectedGroupIds = getSelectedGroupIds(this.state);
if (selectedGroupIds.length > 0) { if (selectedGroupIds.length > 0) {
@@ -3890,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,
) )
); );
}); });
@@ -3922,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!,
@@ -3932,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;
@@ -4277,6 +4294,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
); );
this.hitLinkElement = this.getElementLinkAtPosition( this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer, scenePointer,
hitElement, hitElement,
@@ -4793,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,
@@ -5311,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) => {
@@ -6850,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) {
@@ -8175,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,
); );
@@ -8245,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,
); );
+1 -1
View File
@@ -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={{
+11 -3
View File
@@ -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={{
+1
View File
@@ -55,6 +55,7 @@ export const TopPicks = ({
type="button" type="button"
title={color} title={color}
onClick={() => onChange(color)} onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
</button> </button>
+7 -9
View File
@@ -9,11 +9,7 @@ import {
} from "../actions/shortcuts"; } from "../actions/shortcuts";
import { Action } from "../actions/types"; import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { import { useExcalidrawAppState, useExcalidrawElements } from "./App";
useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import React from "react"; import React from "react";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
@@ -25,14 +21,14 @@ type ContextMenuProps = {
items: ContextMenuItems; items: ContextMenuItems;
top: number; top: number;
left: number; left: number;
onClose: (callback?: () => void) => void;
}; };
export const CONTEXT_MENU_SEPARATOR = "separator"; export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo( export const ContextMenu = React.memo(
({ actionManager, items, top, left }: ContextMenuProps) => { ({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements(); const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
@@ -54,7 +50,9 @@ export const ContextMenu = React.memo(
return ( return (
<Popover <Popover
onCloseRequest={() => setAppState({ contextMenu: null })} onCloseRequest={() => {
onClose();
}}
top={top} top={top}
left={left} left={left}
fitInViewport={true} fitInViewport={true}
@@ -102,7 +100,7 @@ export const ContextMenu = React.memo(
// we need update state before executing the action in case // we need update state before executing the action in case
// the action uses the appState it's being passed (that still // the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state. // contains a defined contextMenu) to return the next state.
setAppState({ contextMenu: null }, () => { onClose(() => {
actionManager.executeAction(item, "contextMenu"); actionManager.executeAction(item, "contextMenu");
}); });
}} }}
+7 -5
View File
@@ -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;
}
}
} }
+5 -3
View File
@@ -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>
-1
View File
@@ -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")]}
+1 -1
View File
@@ -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;
} }
+69 -34
View File
@@ -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
View File
@@ -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)` }
: {} : {}
} }
+1 -1
View File
@@ -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>
); );
+221
View File
@@ -0,0 +1,221 @@
@import "../css/variables.module";
$verticalBreakpoint: 860px;
.excalidraw {
.dialog-mermaid {
&-title {
margin-bottom: 5px;
margin-top: 2px;
}
&-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
}
.Modal__content .Island {
box-shadow: none;
}
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
padding: 1.25rem;
.Modal__content {
height: 100%;
max-height: 750px;
@media screen and (max-width: $verticalBreakpoint) {
height: auto;
// When vertical, we want the height to span whole viewport.
// This is also important for the children not to overflow the
// modal/viewport (for some reason).
max-height: 100%;
}
.Island {
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
.Dialog__content {
display: flex;
flex: 1 1 auto;
}
}
}
}
}
.dialog-mermaid-body {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: 100%;
column-gap: 4rem;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
}
.dialog-mermaid-panels {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
gap: 4rem;
grid-row: 1;
grid-column: 1 / 3;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
margin-bottom: 4px;
margin-left: 4px;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 4px;
}
}
&-text {
display: flex;
flex-direction: column;
textarea {
width: 20rem;
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
&-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
// acts as min-height
height: 200px;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
@media screen and (max-width: $verticalBreakpoint) {
// acts as min-height
height: 400px;
width: auto;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
&-preview-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
&-preview {
display: flex;
flex-direction: column;
}
.mermaid-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
}
.dialog-mermaid-buttons {
grid-column: 2;
.dialog-mermaid-insert {
&.excalidraw-button {
font-family: "Assistant";
font-weight: 600;
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
width: 7.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}
+243
View File
@@ -0,0 +1,243 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../types";
import { useApp } from "./App";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../element/types";
import { canvasToBlob } from "../data/blob";
import { ArrowRightIcon } from "./icons";
import Spinner from "./Spinner";
import "./MermaidToExcalidraw.scss";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { t } from "../i18n";
import Trans from "./Trans";
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const ErrorComp = ({ error }: { error: string }) => {
return (
<div data-testid="mermaid-error" className="mermaid-error">
Error! <p>{error}</p>
</div>
);
};
const MermaidToExcalidraw = () => {
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
loaded: boolean;
api: {
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null;
}>({ loaded: false, api: null });
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
const resetPreview = () => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
useEffect(() => {
const loadMermaidToExcalidrawLib = async () => {
const api = await import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
);
setMermaidToExcalidrawLib({ loaded: true, api });
};
loadMermaidToExcalidrawLib();
}, []);
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
const renderExcalidrawPreview = async () => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (
!mermaidToExcalidrawLib.loaded ||
!canvasNode ||
!parent ||
!mermaidToExcalidrawLib.api
) {
return;
}
if (!deferredText) {
resetPreview();
return;
}
try {
const { elements, files } =
await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
deferredText,
{
fontSize: DEFAULT_FONT_SIZE,
},
);
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (e: any) {
parent.style.background = "var(--default-bg-color)";
if (deferredText) {
setError(e.message);
}
}
};
renderExcalidrawPreview();
}, [deferredText, mermaidToExcalidrawLib]);
const onClose = () => {
app.setOpenDialog(null);
saveMermaidDataToStorage(text);
};
const onSelect = () => {
const { elements: newElements, files } = data.current;
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
onClose();
};
return (
<Dialog
className="dialog-mermaid"
onCloseRequest={onClose}
size={1200}
title={
<>
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
<span className="dialog-mermaid-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
<br />
</span>
</>
}
>
<div className="dialog-mermaid-body">
<div className="dialog-mermaid-panels">
<div className="dialog-mermaid-panels-text">
<label>{t("mermaid.syntax")}</label>
<textarea
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div className="dialog-mermaid-panels-preview">
<label>{t("mermaid.preview")}</label>
<div className="dialog-mermaid-panels-preview-wrapper">
{error && <ErrorComp error={error} />}
{mermaidToExcalidrawLib.loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="dialog-mermaid-panels-preview-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
</div>
</div>
<div className="dialog-mermaid-buttons">
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
{t("mermaid.button")}
<span>{ArrowRightIcon}</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;
+2 -2
View File
@@ -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}
+4 -7
View File
@@ -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;
} }
} }
} }
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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 (
+9
View File
@@ -160,6 +160,15 @@
width: var(--lg-button-size); width: var(--lg-button-size);
height: var(--lg-button-size); height: var(--lg-button-size);
@media screen and (max-width: 450px) {
width: 1.8rem;
height: 1.8rem;
}
@media screen and (max-width: 379px) {
width: 1.5rem;
height: 1.5rem;
}
svg { svg {
width: var(--lg-icon-size); width: var(--lg-icon-size);
height: var(--lg-icon-size); height: var(--lg-icon-size);
+5
View File
@@ -16,6 +16,10 @@
align-self: center; align-self: center;
background-color: var(--default-border-color); background-color: var(--default-border-color);
margin: 0 0.25rem; margin: 0 0.25rem;
@include isMobile {
margin: 0;
}
} }
} }
@@ -41,5 +45,6 @@
margin-top: 0.375rem; margin-top: 0.375rem;
right: 0; right: 0;
min-width: 11.875rem; min-width: 11.875rem;
z-index: 1;
} }
} }
@@ -7,8 +7,6 @@
margin-top: 0.25rem; margin-top: 0.25rem;
&--mobile { &--mobile {
bottom: 55px;
top: auto;
left: 0; left: 0;
width: 100%; width: 100%;
row-gap: 0.75rem; row-gap: 0.75rem;
@@ -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 (
+16
View File
@@ -1654,6 +1654,22 @@ export const frameToolIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const mermaidLogoIcon = createIcon(
<path
fill="currentColor"
d="M407.48,111.18C335.587,108.103 269.573,152.338 245.08,220C220.587,152.338 154.573,108.103 82.68,111.18C80.285,168.229 107.577,222.632 154.74,254.82C178.908,271.419 193.35,298.951 193.27,328.27L193.27,379.13L296.9,379.13L296.9,328.27C296.816,298.953 311.255,271.42 335.42,254.82C382.596,222.644 409.892,168.233 407.48,111.18Z"
/>,
);
export const ArrowRightIcon = createIcon(
<g strokeWidth="1.25">
<path d="M4.16602 10H15.8327" />
<path d="M12.5 13.3333L15.8333 10" />
<path d="M12.5 6.66666L15.8333 9.99999" />
</g>,
modifiedTablerIconProps,
);
export const laserPointerToolIcon = createIcon( export const laserPointerToolIcon = createIcon(
<g <g
fill="none" fill="none"
+2 -2
View File
@@ -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>
)} )}
</> </>
+15 -3
View File
@@ -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";
@@ -148,6 +154,8 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = { export const MIME_TYPES = {
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
@@ -218,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;
@@ -302,6 +308,12 @@ export const ROUGHNESS = {
cartoonist: 2, cartoonist: 2,
} as const; } as const;
export const STROKE_WIDTH = {
thin: 1,
bold: 2,
extraBold: 4,
} as const;
export const DEFAULT_ELEMENT_PROPS: { export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"]; strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"]; backgroundColor: ExcalidrawElement["backgroundColor"];
+12 -5
View File
@@ -195,7 +195,7 @@
.buttonList label:focus-within, .buttonList label:focus-within,
input:focus-visible { input:focus-visible {
outline: transparent; outline: transparent;
box-shadow: 0 0 0 2px var(--focus-highlight-color); box-shadow: 0 0 0 1px var(--color-brand-hover);
} }
.buttonList { .buttonList {
@@ -280,6 +280,11 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px; padding: 8px;
.dropdown-menu--mobile {
bottom: 55px;
top: auto;
}
} }
.App-mobile-menu { .App-mobile-menu {
@@ -537,13 +542,13 @@
&:not(:focus) { &:not(:focus) {
&:hover { &:hover {
background-color: var(--input-hover-bg-color); border-color: var(--color-brand-hover);
} }
} }
&:focus { &:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px var(--focus-highlight-color); border-color: var(--color-brand-hover);
} }
} }
@@ -592,6 +597,8 @@
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
.ToolIcon__icon { .ToolIcon__icon {
width: 2rem;
height: 2rem;
border-radius: 0; border-radius: 0;
} }
@@ -601,8 +608,8 @@
} }
.App-toolbar--mobile { .App-toolbar--mobile {
overflow-x: auto; overflow: visible;
max-width: 90vw; max-width: 98vw;
.ToolIcon__keybinding { .ToolIcon__keybinding {
display: none; display: none;
+140 -140
View File
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "#d8f5a2", "backgroundColor": "#d8f5a2",
"boundElements": [ "boundElements": [
{ {
"id": "id40", "id": "id45",
"type": "arrow", "type": "arrow",
}, },
{ {
"id": "id41", "id": "id46",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -45,7 +45,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id41", "id": "id46",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -97,12 +97,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0.5,
], ],
[ [
395, 394.5,
35, 34.5,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -110,7 +110,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id42", "elementId": "id47",
"focus": -0.08139534883720931, "focus": -0.08139534883720931,
"gap": 1, "gap": 1,
}, },
@@ -150,11 +150,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
400, 399.5,
0, 0,
], ],
], ],
@@ -186,7 +186,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id40", "id": "id45",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -222,7 +222,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0, "baseline": 0,
"boundElements": [ "boundElements": [
{ {
"id": "id43", "id": "id48",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -266,7 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0, "baseline": 0,
"boundElements": [ "boundElements": [
{ {
"id": "id43", "id": "id48",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -309,7 +309,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id44", "id": "id49",
"type": "text", "type": "text",
}, },
], ],
@@ -317,7 +317,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"focus": 0, "focus": 0,
"gap": 5, "gap": 205,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -331,11 +331,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -355,7 +355,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 255, "x": 255,
"y": 239, "y": 239,
} }
@@ -367,7 +367,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id43", "containerId": "id48",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -395,7 +395,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 130,
"x": 340, "x": 240,
"y": 226.5, "y": 226.5,
} }
`; `;
@@ -406,13 +406,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id33", "id": "id38",
"type": "text", "type": "text",
}, },
], ],
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id35", "elementId": "id40",
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -428,11 +428,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id34", "elementId": "id39",
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -452,7 +452,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 255, "x": 255,
"y": 239, "y": 239,
} }
@@ -464,7 +464,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id32", "containerId": "id37",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -492,7 +492,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 130,
"x": 340, "x": 240,
"y": 226.5, "y": 226.5,
} }
`; `;
@@ -503,7 +503,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id32", "id": "id37",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -538,7 +538,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id32", "id": "id37",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -562,7 +562,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 2, "version": 2,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 555, "x": 355,
"y": 189, "y": 189,
} }
`; `;
@@ -573,13 +573,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id37", "id": "id42",
"type": "text", "type": "text",
}, },
], ],
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id39", "elementId": "id44",
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -595,11 +595,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -608,7 +608,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id38", "elementId": "id43",
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 255, "x": 255,
"y": 239, "y": 239,
} }
@@ -631,7 +631,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id36", "containerId": "id41",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -659,7 +659,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 130,
"x": 340, "x": 240,
"y": 226.5, "y": 226.5,
} }
`; `;
@@ -671,7 +671,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0, "baseline": 0,
"boundElements": [ "boundElements": [
{ {
"id": "id36", "id": "id41",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -715,7 +715,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0, "baseline": 0,
"boundElements": [ "boundElements": [
{ {
"id": "id36", "id": "id41",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -747,7 +747,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 555, "x": 355,
"y": 226.5, "y": 226.5,
} }
`; `;
@@ -801,11 +801,11 @@ exports[`Test Transform > should transform linear elements 1`] = `
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -821,7 +821,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 100, "x": 100,
"y": 20, "y": 20,
} }
@@ -846,11 +846,11 @@ exports[`Test Transform > should transform linear elements 2`] = `
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -866,7 +866,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 450, "x": 450,
"y": 20, "y": 20,
} }
@@ -895,7 +895,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
0, 0,
], ],
[ [
300, 100,
0, 0,
], ],
], ],
@@ -911,7 +911,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 100, "x": 100,
"y": 60, "y": 60,
} }
@@ -940,7 +940,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
0, 0,
], ],
[ [
300, 100,
0, 0,
], ],
], ],
@@ -956,7 +956,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 450, "x": 450,
"y": 60, "y": 60,
} }
@@ -1221,56 +1221,6 @@ exports[`Test Transform > should transform text element 2`] = `
`; `;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 1`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id28",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0,
0,
],
[
300,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"x": 100,
"y": 100,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = `
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -1294,11 +1244,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -1314,13 +1264,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 100, "x": 100,
"y": 200, "y": 100,
} }
`; `;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = `
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -1335,7 +1285,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 130, "height": 0,
"id": Any<String>, "id": Any<String>,
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@@ -1344,11 +1294,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -1357,20 +1307,20 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#1098ad", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 2, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 100, "x": 100,
"y": 300, "y": 200,
} }
`; `;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = `
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -1385,7 +1335,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 130, "height": 0,
"id": Any<String>, "id": Any<String>,
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@@ -1394,11 +1344,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -1412,9 +1362,59 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 2, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 100,
"y": 300,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id32",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0.5,
0,
],
[
99.5,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1098ad",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 100,
"x": 100, "x": 100,
"y": 400, "y": 400,
} }
@@ -1426,7 +1426,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id24", "containerId": "id25",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1454,7 +1454,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 130,
"x": 185, "x": 85,
"y": 87.5, "y": 87.5,
} }
`; `;
@@ -1465,7 +1465,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id25", "containerId": "id26",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1493,7 +1493,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 200, "width": 200,
"x": 150, "x": 50,
"y": 187.5, "y": 187.5,
} }
`; `;
@@ -1504,7 +1504,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id26", "containerId": "id27",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1533,7 +1533,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 150, "width": 150,
"x": 175, "x": 75,
"y": 275, "y": 275,
} }
`; `;
@@ -1544,7 +1544,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id27", "containerId": "id28",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1573,7 +1573,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 150, "width": 150,
"x": 175, "x": 75,
"y": 375, "y": 375,
} }
`; `;
@@ -1584,7 +1584,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id18", "id": "id19",
"type": "text", "type": "text",
}, },
], ],
@@ -1619,7 +1619,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id19", "id": "id20",
"type": "text", "type": "text",
}, },
], ],
@@ -1654,7 +1654,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id20", "id": "id21",
"type": "text", "type": "text",
}, },
], ],
@@ -1689,7 +1689,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "#fff3bf", "backgroundColor": "#fff3bf",
"boundElements": [ "boundElements": [
{ {
"id": "id21", "id": "id22",
"type": "text", "type": "text",
}, },
], ],
@@ -1724,7 +1724,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id22", "id": "id23",
"type": "text", "type": "text",
}, },
], ],
@@ -1759,7 +1759,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "#ffec99", "backgroundColor": "#ffec99",
"boundElements": [ "boundElements": [
{ {
"id": "id23", "id": "id24",
"type": "text", "type": "text",
}, },
], ],
@@ -1794,7 +1794,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id12", "containerId": "id13",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1833,7 +1833,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id13", "containerId": "id14",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1873,7 +1873,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id14", "containerId": "id15",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1915,7 +1915,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id15", "containerId": "id16",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1955,7 +1955,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id16", "containerId": "id17",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1996,7 +1996,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id17", "containerId": "id18",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
+66 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}; };
+6 -16
View File
@@ -43,7 +43,6 @@ import {
measureBaseline, measureBaseline,
} from "../element/textElement"; } from "../element/textElement";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
import { isValidFrameChild } from "../frame";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -285,9 +284,6 @@ const restoreElement = (
points, points,
x, x,
y, y,
segmentSplitIndices: element.segmentSplitIndices
? [...element.segmentSplitIndices]
: [],
}); });
} }
@@ -400,7 +396,7 @@ const repairBoundElement = (
}; };
/** /**
* resets `frameId` if no longer applicable. * Remove an element's frameId if its containing frame is non-existent
* *
* NOTE mutates elements. * NOTE mutates elements.
*/ */
@@ -408,16 +404,12 @@ const repairFrameMembership = (
element: Mutable<ExcalidrawElement>, element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>, elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => { ) => {
if (!element.frameId) { if (element.frameId) {
return; const containingFrame = elementsMap.get(element.frameId);
}
if ( if (!containingFrame) {
!isValidFrameChild(element) || element.frameId = null;
// target frame not exists }
!elementsMap.get(element.frameId)
) {
element.frameId = null;
} }
}; };
@@ -461,8 +453,6 @@ export const restoreElements = (
// repair binding. Mutates elements. // repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements); const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) { for (const element of restoredElements) {
// repair frame membership *after* bindings we do in restoreElement()
// since we rely on bindings to be correct
if (element.frameId) { if (element.frameId) {
repairFrameMembership(element, restoredElementsMap); repairFrameMembership(element, restoredElementsMap);
} }
+128 -9
View File
@@ -5,7 +5,31 @@ import {
} from "./transform"; } from "./transform";
import { ExcalidrawArrowElement } from "../element/types"; import { ExcalidrawArrowElement } from "../element/types";
const opts = { regenerateIds: false };
describe("Test Transform", () => { describe("Test Transform", () => {
it("should generate id unless opts.regenerateIds is set to false explicitly", () => {
const elements = [
{
type: "rectangle",
x: 100,
y: 100,
id: "rect-1",
},
];
let data = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(data.length).toBe(1);
expect(data[0].id).toBe("id0");
data = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(data[0].id).toBe("rect-1");
});
it("should transform regular shapes", () => { it("should transform regular shapes", () => {
const elements = [ const elements = [
{ {
@@ -59,6 +83,7 @@ describe("Test Transform", () => {
convertToExcalidrawElements( convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => { ).forEach((ele) => {
expect(ele).toMatchSnapshot({ expect(ele).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),
@@ -87,6 +112,7 @@ describe("Test Transform", () => {
]; ];
convertToExcalidrawElements( convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => { ).forEach((ele) => {
expect(ele).toMatchSnapshot({ expect(ele).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),
@@ -128,6 +154,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
@@ -210,6 +237,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(12); expect(excaldrawElements.length).toBe(12);
@@ -267,6 +295,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(8); expect(excaldrawElements.length).toBe(8);
@@ -280,6 +309,90 @@ describe("Test Transform", () => {
}); });
}); });
describe("Test Frames", () => {
it("should transform frames and update frame ids when regenerated", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
},
];
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
expect(excaldrawElements.length).toBe(4);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchObject({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should consider max of calculated and frame dimensions when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
width: 800,
height: 100,
},
];
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
const frame = excaldrawElements.find((ele) => ele.type === "frame")!;
expect(frame.width).toBe(800);
expect(frame.height).toBe(126);
});
});
describe("Test arrow bindings", () => { describe("Test arrow bindings", () => {
it("should bind arrows to shapes when start / end provided without ids", () => { it("should bind arrows to shapes when start / end provided without ids", () => {
const elements = [ const elements = [
@@ -300,6 +413,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
@@ -321,7 +435,7 @@ describe("Test Transform", () => {
}); });
expect(text).toMatchObject({ expect(text).toMatchObject({
x: 340, x: 240,
y: 226.5, y: 226.5,
type: "text", type: "text",
text: "HELLO WORLD!!", text: "HELLO WORLD!!",
@@ -341,7 +455,7 @@ describe("Test Transform", () => {
}); });
expect(ellipse).toMatchObject({ expect(ellipse).toMatchObject({
x: 555, x: 355,
y: 189, y: 189,
type: "ellipse", type: "ellipse",
boundElements: [ boundElements: [
@@ -383,10 +497,10 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excaldrawElements; const [arrow, text1, text2, text3] = excaldrawElements;
expect(arrow).toMatchObject({ expect(arrow).toMatchObject({
@@ -406,7 +520,7 @@ describe("Test Transform", () => {
}); });
expect(text1).toMatchObject({ expect(text1).toMatchObject({
x: 340, x: 240,
y: 226.5, y: 226.5,
type: "text", type: "text",
text: "HELLO WORLD!!", text: "HELLO WORLD!!",
@@ -426,7 +540,7 @@ describe("Test Transform", () => {
}); });
expect(text3).toMatchObject({ expect(text3).toMatchObject({
x: 555, x: 355,
y: 226.5, y: 226.5,
type: "text", type: "text",
boundElements: [ boundElements: [
@@ -499,6 +613,7 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(5); expect(excaldrawElements.length).toBe(5);
@@ -547,6 +662,7 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
@@ -600,17 +716,18 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
const [, , arrow] = excaldrawElements; const [, , arrow, text] = excaldrawElements;
expect(arrow).toMatchObject({ expect(arrow).toMatchObject({
type: "arrow", type: "arrow",
x: 255, x: 255,
y: 239, y: 239,
boundElements: [ boundElements: [
{ {
id: "id46", id: text.id,
type: "text", type: "text",
}, },
], ],
@@ -650,17 +767,18 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(2); expect(excaldrawElements.length).toBe(2);
const [arrow, rect] = excaldrawElements; const [arrow, rect] = excaldrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
focus: 0, focus: 0,
gap: 5, gap: 205,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {
id: "id47", id: arrow.id,
type: "arrow", type: "arrow",
}, },
]); ]);
@@ -692,6 +810,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(1); expect(excaldrawElements.length).toBe(1);
+161 -12
View File
@@ -5,6 +5,7 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { import {
getCommonBounds,
newElement, newElement,
newLinearElement, newLinearElement,
redrawTextBoundingBox, redrawTextBoundingBox,
@@ -12,6 +13,7 @@ import {
import { bindLinearElement } from "../element/binding"; import { bindLinearElement } from "../element/binding";
import { import {
ElementConstructorOpts, ElementConstructorOpts,
newFrameElement,
newImageElement, newImageElement,
newTextElement, newTextElement,
} from "../element/newElement"; } from "../element/newElement";
@@ -38,7 +40,9 @@ 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 { randomId } from "../random";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
@@ -133,9 +137,7 @@ export type ValidContainer =
export type ExcalidrawElementSkeleton = export type ExcalidrawElementSkeleton =
| Extract< | Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
| ExcalidrawEmbeddableElement ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement
| ExcalidrawFreeDrawElement
| ExcalidrawFrameElement
> >
| ({ | ({
type: Extract<ExcalidrawLinearElement["type"], "line">; type: Extract<ExcalidrawLinearElement["type"], "line">;
@@ -156,10 +158,15 @@ export type ExcalidrawElementSkeleton =
x: number; x: number;
y: number; y: number;
fileId: FileId; fileId: FileId;
} & Partial<ExcalidrawImageElement>); } & Partial<ExcalidrawImageElement>)
| ({
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = { const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 300, width: 100,
height: 0, height: 0,
}; };
@@ -357,6 +364,49 @@ const bindLinearElementToElement = (
); );
} }
} }
// 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 delta = 0.5;
const newPoints = cloneJSON(linearElement.points) as [number, number][];
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = delta;
newPoints[endPointIndex][0] -= delta;
}
// right to left so shift the arrow towards left
if (
linearElement.points[endPointIndex][0] <
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = -delta;
newPoints[endPointIndex][0] += delta;
}
// top to bottom so shift the arrow towards top
if (
linearElement.points[endPointIndex][1] >
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = delta;
newPoints[endPointIndex][1] -= delta;
}
// bottom to top so shift the arrow towards bottom
if (
linearElement.points[endPointIndex][1] <
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = -delta;
newPoints[endPointIndex][1] += delta;
}
Object.assign(linearElement, { points: newPoints });
return { return {
linearElement, linearElement,
startBoundElement, startBoundElement,
@@ -384,18 +434,25 @@ class ElementStore {
} }
export const convertToExcalidrawElements = ( export const convertToExcalidrawElements = (
elements: ExcalidrawElementSkeleton[] | null, elementsSkeleton: ExcalidrawElementSkeleton[] | null,
opts?: { regenerateIds: boolean },
) => { ) => {
if (!elements) { if (!elementsSkeleton) {
return []; return [];
} }
const elements = cloneJSON(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>();
// Create individual elements // Create individual elements
for (const element of elements) { for (const element of elements) {
let excalidrawElement: ExcalidrawElement; let excalidrawElement: ExcalidrawElement;
const originalId = element.id;
if (opts?.regenerateIds !== false) {
Object.assign(element, { id: randomId() });
}
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "ellipse": case "ellipse":
@@ -444,6 +501,11 @@ export const convertToExcalidrawElements = (
], ],
...element, ...element,
}); });
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
break; break;
} }
case "text": { case "text": {
@@ -477,8 +539,15 @@ export const convertToExcalidrawElements = (
break; break;
} }
case "frame": {
excalidrawElement = newFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw": case "freedraw":
case "frame":
case "embeddable": { case "embeddable": {
excalidrawElement = element; excalidrawElement = element;
break; break;
@@ -499,6 +568,9 @@ export const convertToExcalidrawElements = (
} else { } else {
elementStore.add(excalidrawElement); elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element); elementsWithIds.set(excalidrawElement.id, element);
if (originalId) {
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
}
} }
} }
@@ -524,6 +596,18 @@ export const convertToExcalidrawElements = (
element.type === "arrow" ? element?.start : undefined; element.type === "arrow" ? element?.start : undefined;
const originalEnd = const originalEnd =
element.type === "arrow" ? element?.end : undefined; element.type === "arrow" ? element?.end : undefined;
if (originalStart && originalStart.id) {
const newStartId = oldToNewElementIdMap.get(originalStart.id);
if (newStartId) {
Object.assign(originalStart, { id: newStartId });
}
}
if (originalEnd && originalEnd.id) {
const newEndId = oldToNewElementIdMap.get(originalEnd.id);
if (newEndId) {
Object.assign(originalEnd, { id: newEndId });
}
}
const { linearElement, startBoundElement, endBoundElement } = const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement( bindLinearElementToElement(
container as ExcalidrawArrowElement, container as ExcalidrawArrowElement,
@@ -539,13 +623,23 @@ export const convertToExcalidrawElements = (
} else { } else {
switch (element.type) { switch (element.type) {
case "arrow": { case "arrow": {
const { start, end } = element;
if (start && start.id) {
const newStartId = oldToNewElementIdMap.get(start.id);
Object.assign(start, { id: newStartId });
}
if (end && end.id) {
const newEndId = oldToNewElementIdMap.get(end.id);
Object.assign(end, { id: newEndId });
}
const { linearElement, startBoundElement, endBoundElement } = const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement( bindLinearElementToElement(
excalidrawElement as ExcalidrawArrowElement, excalidrawElement as ExcalidrawArrowElement,
element.start, start,
element.end, end,
elementStore, elementStore,
); );
elementStore.add(linearElement); elementStore.add(linearElement);
elementStore.add(startBoundElement); elementStore.add(startBoundElement);
elementStore.add(endBoundElement); elementStore.add(endBoundElement);
@@ -557,5 +651,60 @@ export const convertToExcalidrawElements = (
} }
} }
} }
// Once all the excalidraw elements are created, we can add frames since we
// need to calculate coordinates and dimensions of frame which is possibe after all
// frame children are processed.
for (const [id, element] of elementsWithIds) {
if (element.type !== "frame") {
continue;
}
const frame = elementStore.getElement(id);
if (!frame) {
throw new Error(`Excalidraw element with id ${id} doesn't exist`);
}
const childrenElements: ExcalidrawElement[] = [];
element.children.forEach((id) => {
const newElementId = oldToNewElementIdMap.get(id);
if (!newElementId) {
throw new Error(`Element with ${id} wasn't mapped correctly`);
}
const elementInFrame = elementStore.getElement(newElementId);
if (!elementInFrame) {
throw new Error(`Frame element with id ${newElementId} doesn't exist`);
}
Object.assign(elementInFrame, { frameId: frame.id });
elementInFrame?.boundElements?.forEach((boundElement) => {
const ele = elementStore.getElement(boundElement.id);
if (!ele) {
throw new Error(
`Bound element with id ${boundElement.id} doesn't exist`,
);
}
Object.assign(ele, { frameId: frame.id });
childrenElements.push(ele);
});
childrenElements.push(elementInFrame);
});
let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
const PADDING = 10;
minX = minX - PADDING;
minY = minY - PADDING;
maxX = maxX + PADDING;
maxY = maxY + PADDING;
// Take the max of calculated and provided frame dimensions, whichever is higher
const width = Math.max(frame?.width, maxX - minX);
const height = Math.max(frame?.height, maxY - minY);
Object.assign(frame, { x: minX, y: minY, width, height });
}
return elementStore.getElements(); return elementStore.getElements();
}; };
+1 -1
View File
@@ -392,7 +392,7 @@ export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
angle: number, angle: number,
appState: Pick<UIAppState, "zoom">, appState: Pick<UIAppState, "zoom">,
): [x: number, y: number, width: number, height: number] => { ): Bounds => {
const size = DEFAULT_LINK_SIZE; const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value; const linkWidth = size / appState.zoom.value;
const linkHeight = size / appState.zoom.value; const linkHeight = size / appState.zoom.value;
-10
View File
@@ -27,7 +27,6 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { isValidFrameChild } from "../frame";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@@ -212,15 +211,6 @@ export const bindLinearElement = (
}), }),
}); });
} }
if (linearElement.frameId && !isValidFrameChild(linearElement)) {
mutateElement(
linearElement,
{
frameId: null,
},
false,
);
}
}; };
// Don't bind both ends of a simple segment // Don't bind both ends of a simple segment
+17 -17
View File
@@ -34,7 +34,12 @@ export type RectangleBox = {
type MaybeQuadraticSolution = [number | null, number | null] | false; type MaybeQuadraticSolution = [number | null, number | null] | false;
// x and y position of top left corner, x and y position of bottom right corner // x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number]; export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export class ElementBounds { export class ElementBounds {
private static boundsCache = new WeakMap< private static boundsCache = new WeakMap<
@@ -63,7 +68,7 @@ export class ElementBounds {
} }
private static calculateBounds(element: ExcalidrawElement): Bounds { private static calculateBounds(element: ExcalidrawElement): Bounds {
let bounds: [number, number, number, number]; let bounds: Bounds;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
@@ -387,7 +392,7 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = ( export const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (x: number, y: number) => [number, number], transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => { ): Bounds => {
let currentP: Point = [0, 0]; let currentP: Point = [0, 0];
const { minX, minY, maxX, maxY } = ops.reduce( const { minX, minY, maxX, maxY } = ops.reduce(
@@ -435,9 +440,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
const getBoundsFromPoints = ( export const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"], points: ExcalidrawFreeDrawElement["points"],
): [number, number, number, number] => { ): Bounds => {
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
@@ -589,7 +594,7 @@ const getLinearElementRotatedBounds = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
cx: number, cx: number,
cy: number, cy: number,
): [number, number, number, number] => { ): Bounds => {
if (element.points.length < 2) { if (element.points.length < 2) {
const [pointX, pointY] = element.points[0]; const [pointX, pointY] = element.points[0];
const [x, y] = rotate( const [x, y] = rotate(
@@ -600,7 +605,7 @@ const getLinearElementRotatedBounds = (
element.angle, element.angle,
); );
let coords: [number, number, number, number] = [x, y, x, y]; let coords: Bounds = [x, y, x, y];
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
@@ -625,12 +630,7 @@ const getLinearElementRotatedBounds = (
const transformXY = (x: number, y: number) => const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle); rotate(element.x + x, element.y + y, cx, cy, element.angle);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY); const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: [number, number, number, number] = [ let coords: Bounds = [res[0], res[1], res[2], res[3]];
res[0],
res[1],
res[2],
res[3],
];
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
@@ -692,7 +692,7 @@ export const getResizedElementAbsoluteCoords = (
nextWidth: number, nextWidth: number,
nextHeight: number, nextHeight: number,
normalizePoints: boolean, normalizePoints: boolean,
): [number, number, number, number] => { ): Bounds => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) { if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [ return [
element.x, element.x,
@@ -709,7 +709,7 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints, normalizePoints,
); );
let bounds: [number, number, number, number]; let bounds: Bounds;
if (isFreeDrawElement(element)) { if (isFreeDrawElement(element)) {
// Free Draw // Free Draw
@@ -740,8 +740,8 @@ export const getResizedElementAbsoluteCoords = (
export const getElementPointsCoords = ( export const getElementPointsCoords = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[], points: readonly (readonly [number, number])[],
): [number, number, number, number] => { ): Bounds => {
// This might be computationally heavy // This might be computationally heavey
const gen = rough.generator(); const gen = rough.generator();
const curve = const curve =
element.roundness == null element.roundness == null
+3 -1
View File
@@ -494,7 +494,9 @@ const hitTestFreeDrawElement = (
// for filled freedraw shapes, support // for filled freedraw shapes, support
// selecting from inside // selecting from inside
if (shape && shape.sets.length) { if (shape && shape.sets.length) {
return hitTestCurveInside(shape, x, y, "round"); return element.fillStyle === "solid"
? hitTestCurveInside(shape, x, y, "round")
: hitTestRoughShape(shape, x, y, threshold);
} }
return false; return false;
+51 -37
View File
@@ -1,5 +1,5 @@
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds"; import { Bounds, getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import { NonDeletedExcalidrawElement } from "./types"; import { NonDeletedExcalidrawElement } from "./types";
@@ -8,7 +8,11 @@ import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups"; import { isSelectedViaGroup } from "../groups";
import { getGridPoint } from "../math"; import { getGridPoint } from "../math";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks"; import {
isArrowElement,
isBoundToContainer,
isFrameElement,
} from "./typeChecks";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@@ -35,44 +39,41 @@ export const dragSelectedElements = (
if (frames.length > 0) { if (frames.length > 0) {
const elementsInFrames = scene const elementsInFrames = scene
.getNonDeletedElements() .getNonDeletedElements()
.filter((e) => !isBoundToContainer(e))
.filter((e) => e.frameId !== null) .filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!)); .filter((e) => frames.includes(e.frameId!));
elementsInFrames.forEach((element) => elementsToUpdate.add(element)); elementsInFrames.forEach((element) => elementsToUpdate.add(element));
} }
const commonBounds = getCommonBounds(
Array.from(elementsToUpdate).map(
(el) => pointerDownState.originalElements.get(el.id) ?? el,
),
);
const adjustedOffset = calculateOffset(
commonBounds,
offset,
snapOffset,
gridSize,
);
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
updateElementCoords( updateElementCoords(pointerDownState, element, adjustedOffset);
pointerDownState,
element,
offset,
snapOffset,
gridSize,
);
// update coords of bound text only if we're dragging the container directly // update coords of bound text only if we're dragging the container directly
// (we don't drag the group that it's part of) // (we don't drag the group that it's part of)
if ( if (
// Don't update coords of arrow label since we calculate its position during render
!isArrowElement(element) &&
// container isn't part of any group // container isn't part of any group
// (perf optim so we don't check `isSelectedViaGroup()` in every case) // (perf optim so we don't check `isSelectedViaGroup()` in every case)
!element.groupIds.length || (!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly // container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element)) (appState.editingGroupId && !isSelectedViaGroup(appState, element)))
) { ) {
const textElement = getBoundTextElement(element); const textElement = getBoundTextElement(element);
if ( if (textElement) {
textElement && updateElementCoords(pointerDownState, textElement, adjustedOffset);
// when container is added to a frame, so will its bound text
// so the text is already in `elementsToUpdate` and we should avoid
// updating its coords again
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
updateElementCoords(
pointerDownState,
textElement,
offset,
snapOffset,
gridSize,
);
} }
} }
updateBoundElements(element, { updateBoundElements(element, {
@@ -81,23 +82,20 @@ export const dragSelectedElements = (
}); });
}; };
const updateElementCoords = ( const calculateOffset = (
pointerDownState: PointerDownState, commonBounds: Bounds,
element: NonDeletedExcalidrawElement,
dragOffset: { x: number; y: number }, dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number }, snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"], gridSize: AppState["gridSize"],
) => { ): { x: number; y: number } => {
const originalElement = const [x, y] = commonBounds;
pointerDownState.originalElements.get(element.id) ?? element; let nextX = x + dragOffset.x + snapOffset.x;
let nextY = y + dragOffset.y + snapOffset.y;
let nextX = originalElement.x + dragOffset.x + snapOffset.x;
let nextY = originalElement.y + dragOffset.y + snapOffset.y;
if (snapOffset.x === 0 || snapOffset.y === 0) { if (snapOffset.x === 0 || snapOffset.y === 0) {
const [nextGridX, nextGridY] = getGridPoint( const [nextGridX, nextGridY] = getGridPoint(
originalElement.x + dragOffset.x, x + dragOffset.x,
originalElement.y + dragOffset.y, y + dragOffset.y,
gridSize, gridSize,
); );
@@ -109,6 +107,22 @@ const updateElementCoords = (
nextY = nextGridY; nextY = nextGridY;
} }
} }
return {
x: nextX - x,
y: nextY - y,
};
};
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
dragOffset: { x: number; y: number },
) => {
const originalElement =
pointerDownState.originalElements.get(element.id) ?? element;
const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y;
mutateElement(element, { mutateElement(element, {
x: nextX, x: nextX,
+10 -1
View File
@@ -48,6 +48,9 @@ const RE_VALTOWN =
const RE_GENERIC_EMBED = const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i; /^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
const RE_GIPHY =
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
@@ -60,6 +63,8 @@ const ALLOWED_DOMAINS = new Set([
"*.simplepdf.eu", "*.simplepdf.eu",
"stackblitz.com", "stackblitz.com",
"val.town", "val.town",
"giphy.com",
"dddice.com",
]); ]);
const createSrcDoc = (body: string) => { const createSrcDoc = (body: string) => {
@@ -196,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)) {
@@ -309,6 +314,10 @@ export const extractSrc = (htmlString: string): string => {
return gistMatch[1]; return gistMatch[1];
} }
if (RE_GIPHY.test(htmlString)) {
return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`;
}
const match = htmlString.match(RE_GENERIC_EMBED); const match = htmlString.match(RE_GENERIC_EMBED);
if (match && match.length === 2) { if (match && match.length === 2) {
return match[1]; return match[1];
+8 -56
View File
@@ -21,6 +21,7 @@ import {
} from "../math"; } from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import { import {
Bounds,
getCurvePathOps, getCurvePathOps,
getElementPointsCoords, getElementPointsCoords,
getMinMaxXYFromCurvePathOps, getMinMaxXYFromCurvePathOps,
@@ -547,10 +548,7 @@ export class LinearElementEditor {
endPointIndex: number, endPointIndex: number,
) { ) {
let segmentMidPoint = centerPoint(startPoint, endPoint); let segmentMidPoint = centerPoint(startPoint, endPoint);
const splits = element.segmentSplitIndices || []; if (element.points.length > 2 && element.roundness) {
const treatAsCurve =
splits.includes(endPointIndex) || splits.includes(endPointIndex - 1);
if (element.points.length > 2 && (element.roundness || treatAsCurve)) {
const controlPoints = getControlPointsForBezierCurve( const controlPoints = getControlPointsForBezierCurve(
element, element,
element.points[endPointIndex], element.points[endPointIndex],
@@ -1045,15 +1043,13 @@ export class LinearElementEditor {
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
const indexSet = new Set(pointIndices); const isDeletingOriginPoint = pointIndices.includes(0);
const isDeletingOriginPoint = indexSet.has(0);
// if deleting first point, make the next to be [0,0] and recalculate // if deleting first point, make the next to be [0,0] and recalculate
// positions of the rest with respect to it // positions of the rest with respect to it
if (isDeletingOriginPoint) { if (isDeletingOriginPoint) {
const firstNonDeletedPoint = element.points.find((point, idx) => { const firstNonDeletedPoint = element.points.find((point, idx) => {
return !indexSet.has(idx); return !pointIndices.includes(idx);
}); });
if (firstNonDeletedPoint) { if (firstNonDeletedPoint) {
offsetX = firstNonDeletedPoint[0]; offsetX = firstNonDeletedPoint[0];
@@ -1062,7 +1058,7 @@ export class LinearElementEditor {
} }
const nextPoints = element.points.reduce((acc: Point[], point, idx) => { const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
if (!indexSet.has(idx)) { if (!pointIndices.includes(idx)) {
acc.push( acc.push(
!acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY], !acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
); );
@@ -1070,22 +1066,7 @@ export class LinearElementEditor {
return acc; return acc;
}, []); }, []);
const splits: number[] = []; LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
(element.segmentSplitIndices || []).forEach((index) => {
if (!indexSet.has(index)) {
let shift = 0;
for (const pointIndex of pointIndices) {
if (index > pointIndex) {
shift++;
}
}
splits.push(index - shift);
}
});
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY, {
segmentSplitIndices: splits.sort((a, b) => a - b),
});
} }
static addPoints( static addPoints(
@@ -1224,13 +1205,9 @@ export class LinearElementEditor {
midpoint, midpoint,
...element.points.slice(segmentMidpoint.index!), ...element.points.slice(segmentMidpoint.index!),
]; ];
const splits = (element.segmentSplitIndices || []).map((index) =>
index >= segmentMidpoint.index! ? index + 1 : index,
);
mutateElement(element, { mutateElement(element, {
points, points,
segmentSplitIndices: splits.sort((a, b) => a - b),
}); });
ret.pointerDownState = { ret.pointerDownState = {
@@ -1250,11 +1227,7 @@ export class LinearElementEditor {
nextPoints: readonly Point[], nextPoints: readonly Point[],
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
otherUpdates?: { otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
startBinding?: PointBinding;
endBinding?: PointBinding;
segmentSplitIndices?: number[];
},
) { ) {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);
@@ -1344,7 +1317,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = ( static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
elementBounds: [number, number, number, number], elementBounds: Bounds,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => { ): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds; let [x1, y1, x2, y2] = elementBounds;
@@ -1500,27 +1473,6 @@ export class LinearElementEditor {
return coords; return coords;
}; };
static toggleSegmentSplitAtIndex(
element: NonDeleted<ExcalidrawLinearElement>,
index: number,
) {
let found = false;
const splitIndices = (element.segmentSplitIndices || []).filter((idx) => {
if (idx === index) {
found = true;
return false;
}
return true;
});
if (!found) {
splitIndices.push(index);
}
mutateElement(element, {
segmentSplitIndices: splitIndices.sort((a, b) => a - b),
});
}
} }
const normalizeSelectedPoints = ( const normalizeSelectedPoints = (
+1 -2
View File
@@ -25,7 +25,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fileId, segmentSplitIndices } = updates as any; const { points, fileId } = updates as any;
if (typeof points !== "undefined") { if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates }; updates = { ...getSizeFromPoints(points), ...updates };
@@ -86,7 +86,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
if ( if (
typeof updates.height !== "undefined" || typeof updates.height !== "undefined" ||
typeof updates.width !== "undefined" || typeof updates.width !== "undefined" ||
typeof segmentSplitIndices !== "undefined" ||
typeof fileId != "undefined" || typeof fileId != "undefined" ||
typeof points !== "undefined" typeof points !== "undefined"
) { ) {
+4 -3
View File
@@ -144,13 +144,15 @@ export const newEmbeddableElement = (
}; };
export const newFrameElement = ( export const newFrameElement = (
opts: ElementConstructorOpts, opts: {
name?: string;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFrameElement> => { ): NonDeleted<ExcalidrawFrameElement> => {
const frameElement = newElementWith( const frameElement = newElementWith(
{ {
..._newElementBase<ExcalidrawFrameElement>("frame", opts), ..._newElementBase<ExcalidrawFrameElement>("frame", opts),
type: "frame", type: "frame",
name: null, name: opts?.name || null,
}, },
{}, {},
); );
@@ -374,7 +376,6 @@ export const newLinearElement = (
endBinding: null, endBinding: null,
startArrowhead: opts.startArrowhead || null, startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null, endArrowhead: opts.endArrowhead || null,
segmentSplitIndices: [],
}; };
}; };
+2 -1
View File
@@ -13,6 +13,7 @@ import {
MaybeTransformHandleType, MaybeTransformHandleType,
} from "./transformHandles"; } from "./transformHandles";
import { AppState, Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { Bounds } from "./bounds";
const isInsideTransformHandle = ( const isInsideTransformHandle = (
transformHandle: TransformHandle, transformHandle: TransformHandle,
@@ -87,7 +88,7 @@ export const getElementWithTransformHandleType = (
}; };
export const getTransformHandleTypeFromCoords = ( export const getTransformHandleTypeFromCoords = (
[x1, y1, x2, y2]: readonly [number, number, number, number], [x1, y1, x2, y2]: Bounds,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
zoom: Zoom, zoom: Zoom,
+1 -1
View File
@@ -91,7 +91,7 @@ export const redrawTextBoundingBox = (
); );
const maxContainerWidth = getBoundTextMaxWidth(container); const maxContainerWidth = getBoundTextMaxWidth(container);
if (metrics.height > maxContainerHeight) { if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
const nextHeight = computeContainerDimensionForBoundText( const nextHeight = computeContainerDimensionForBoundText(
metrics.height, metrics.height,
container.type, container.type,
+57 -58
View File
@@ -18,7 +18,7 @@ import {
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
import { getTextEditor } from "../tests/queries/dom"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -26,10 +26,7 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const tab = " "; const tab = " ";
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => { const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
fireEvent.change(editor, { target: { value } });
editor.dispatchEvent(new Event("input"));
};
describe("textWysiwyg", () => { describe("textWysiwyg", () => {
describe("start text editing", () => { describe("start text editing", () => {
@@ -195,7 +192,7 @@ describe("textWysiwyg", () => {
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = await getTextEditor(false); const editor = await getTextEditor(textEditorSelector, false);
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingElement?.id).toBe(text.id);
@@ -217,12 +214,26 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + 50, text.y + 50); mouse.doubleClickAt(text.x + 50, text.y + 50);
const editor = await getTextEditor(false); const editor = await getTextEditor(textEditorSelector, false);
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingElement?.id).toBe(text.id);
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
// FIXME too flaky. No one knows why.
it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
});
await UI.editText(arrow, "Hello");
const { version } = arrow;
await UI.editText(arrow, "Hello\nworld!");
expect(arrow.version).toEqual(version + 1);
});
}); });
describe("Test container-unbound text", () => { describe("Test container-unbound text", () => {
@@ -238,13 +249,15 @@ 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");
mouse.clickOn(textElement); mouse.clickOn(textElement);
textarea = await getTextEditor(true); textarea = await getTextEditor(textEditorSelector, true);
}); });
afterAll(() => { afterAll(() => {
@@ -454,7 +467,7 @@ describe("textWysiwyg", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(750, 300); mouse.clickAt(750, 300);
textarea = await getTextEditor(true); textarea = await getTextEditor(textEditorSelector, true);
updateTextEditor( updateTextEditor(
textarea, textarea,
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!", "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
@@ -506,7 +519,7 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
]); ]);
mouse.down(); mouse.down();
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
@@ -534,7 +547,7 @@ describe("textWysiwyg", () => {
]); ]);
expect(text.angle).toBe(rectangle.angle); expect(text.angle).toBe(rectangle.angle);
mouse.down(); mouse.down();
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
@@ -561,7 +574,7 @@ describe("textWysiwyg", () => {
API.setSelectedElements([diamond]); API.setSelectedElements([diamond]);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
const value = new Array(1000).fill("1").join("\n"); const value = new Array(1000).fill("1").join("\n");
@@ -596,7 +609,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(null); expect(text.containerId).toBe(null);
mouse.down(); mouse.down();
let editor = await getTextEditor(true); let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@@ -611,7 +624,7 @@ describe("textWysiwyg", () => {
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
mouse.down(); mouse.down();
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@@ -633,7 +646,7 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@@ -668,7 +681,7 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
]); ]);
mouse.down(); mouse.down();
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@@ -693,7 +706,7 @@ describe("textWysiwyg", () => {
freedraw.y + freedraw.height / 2, freedraw.y + freedraw.height / 2,
); );
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
@@ -727,7 +740,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(null); expect(text.containerId).toBe(null);
mouse.down(); mouse.down();
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
@@ -742,7 +755,7 @@ describe("textWysiwyg", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor( updateTextEditor(
editor, editor,
@@ -787,7 +800,7 @@ describe("textWysiwyg", () => {
mouse.down(); mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = await getTextEditor(true); let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
@@ -800,7 +813,7 @@ describe("textWysiwyg", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
); );
mouse.down(); mouse.down();
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
editor.select(); editor.select();
fireEvent.click(screen.getByTitle(/code/i)); fireEvent.click(screen.getByTitle(/code/i));
@@ -833,7 +846,7 @@ describe("textWysiwyg", () => {
Keyboard.keyDown(KEYS.ENTER); Keyboard.keyDown(KEYS.ENTER);
let text = h.elements[1] as ExcalidrawTextElementWithContainer; let text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = await getTextEditor(true); let editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
@@ -854,7 +867,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello"); updateTextEditor(editor, "Hello");
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@@ -883,7 +896,7 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@@ -920,7 +933,7 @@ describe("textWysiwyg", () => {
// Bind first text // Bind first text
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
@@ -941,7 +954,7 @@ describe("textWysiwyg", () => {
it("should respect text alignment when resizing", async () => { it("should respect text alignment when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
let editor = await getTextEditor(true); let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello"); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
@@ -958,7 +971,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
editor.select(); editor.select();
@@ -981,7 +994,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
editor.select(); editor.select();
@@ -1019,7 +1032,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
mouse.down(); mouse.down();
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
@@ -1034,7 +1047,7 @@ describe("textWysiwyg", () => {
it("should scale font size correctly when resizing using shift", async () => { it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello"); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
@@ -1054,7 +1067,7 @@ describe("textWysiwyg", () => {
it("should bind text correctly when container duplicated with alt-drag", async () => { it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello"); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
@@ -1086,7 +1099,7 @@ describe("textWysiwyg", () => {
it("undo should work", async () => { it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello"); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
@@ -1123,7 +1136,7 @@ describe("textWysiwyg", () => {
it("should not allow bound text with only whitespaces", async () => { it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, " "); updateTextEditor(editor, " ");
@@ -1178,7 +1191,7 @@ describe("textWysiwyg", () => {
it("should reset the container height cache when resizing", async () => { it("should reset the container height cache when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = await getTextEditor(true); let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello"); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
@@ -1190,7 +1203,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@@ -1206,7 +1219,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
@@ -1231,7 +1244,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
expect( expect(
@@ -1263,12 +1276,12 @@ describe("textWysiwyg", () => {
beforeEach(async () => { beforeEach(async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello"); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
editor.select(); editor.select();
}); });
@@ -1379,7 +1392,7 @@ describe("textWysiwyg", () => {
it("should wrap text in a container when wrap text in container triggered from context menu", async () => { it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = await getTextEditor(true); const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor( updateTextEditor(
editor, editor,
@@ -1467,7 +1480,7 @@ describe("textWysiwyg", () => {
// Bind first text // Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer; let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
let editor = await getTextEditor(true); let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
expect( expect(
@@ -1492,7 +1505,7 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2, rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
); );
editor = await getTextEditor(true); editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Excalidraw"); updateTextEditor(editor, "Excalidraw");
editor.blur(); editor.blur();
@@ -1506,18 +1519,4 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Excalidraw"); expect(text.text).toBe("Excalidraw");
}); });
}); });
it("should bump the version of a labeled arrow when the label is updated", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
});
await UI.editText(arrow, "Hello");
const { version } = arrow;
await UI.editText(arrow, "Hello\nworld!");
expect(arrow.version).toEqual(version + 1);
});
}); });
+2 -2
View File
@@ -4,7 +4,7 @@ import {
PointerType, PointerType,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords } from "./bounds"; import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { InteractiveCanvasAppState, Zoom } from "../types"; import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
@@ -23,7 +23,7 @@ export type TransformHandleDirection =
export type TransformHandleType = TransformHandleDirection | "rotation"; export type TransformHandleType = TransformHandleDirection | "rotation";
export type TransformHandle = [number, number, number, number]; export type TransformHandle = Bounds;
export type TransformHandles = Partial<{ export type TransformHandles = Partial<{
[T in TransformHandleType]: TransformHandle; [T in TransformHandleType]: TransformHandle;
}>; }>;
+27 -11
View File
@@ -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 = (
-1
View File
@@ -195,7 +195,6 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "line" | "arrow"; type: "line" | "arrow";
points: readonly Point[]; points: readonly Point[];
segmentSplitIndices: readonly number[] | null;
lastCommittedPoint: Point | null; lastCommittedPoint: Point | null;
startBinding: PointBinding | null; startBinding: PointBinding | null;
endBinding: PointBinding | null; endBinding: PointBinding | null;
+13 -13
View File
@@ -123,7 +123,7 @@ describe("adding elements to frames", () => {
const commonTestCases = async ( const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => { ) => {
describe("when frame is in a layer below", async () => { describe.skip("when frame is in a layer below", async () => {
it("should add an element", async () => { it("should add an element", async () => {
h.elements = [frame, rect2]; h.elements = [frame, rect2];
@@ -167,7 +167,7 @@ describe("adding elements to frames", () => {
}); });
}); });
describe("when frame is in a layer above", async () => { describe.skip("when frame is in a layer above", async () => {
it("should add an element", async () => { it("should add an element", async () => {
h.elements = [rect2, frame]; h.elements = [rect2, frame];
@@ -212,7 +212,7 @@ describe("adding elements to frames", () => {
}); });
describe("when frame is in an inner layer", async () => { describe("when frame is in an inner layer", async () => {
it("should add elements", async () => { it.skip("should add elements", async () => {
h.elements = [rect2, frame, rect3]; h.elements = [rect2, frame, rect3];
func(frame, rect2); func(frame, rect2);
@@ -223,7 +223,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, rect3, frame]); expectEqualIds([rect2, rect3, frame]);
}); });
it("should add elements when there are other other elements in between", async () => { it.skip("should add elements when there are other other elements in between", async () => {
h.elements = [rect2, rect1, frame, rect4, rect3]; h.elements = [rect2, rect1, frame, rect4, rect3];
func(frame, rect2); func(frame, rect2);
@@ -234,7 +234,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]); expectEqualIds([rect1, rect2, rect3, frame, rect4]);
}); });
it("should add elements when there are other elements in between and the order is reversed", async () => { it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [rect3, rect4, frame, rect2, rect1]; h.elements = [rect3, rect4, frame, rect2, rect1];
func(frame, rect2); func(frame, rect2);
@@ -289,7 +289,7 @@ describe("adding elements to frames", () => {
describe("resizing frame over elements", async () => { describe("resizing frame over elements", async () => {
await commonTestCases(resizeFrameOverElement); await commonTestCases(resizeFrameOverElement);
it("resizing over text containers and labelled arrows", async () => { it.skip("resizing over text containers and labelled arrows", async () => {
await resizingTest( await resizingTest(
"rectangle", "rectangle",
["frame", "rectangle", "text"], ["frame", "rectangle", "text"],
@@ -339,7 +339,7 @@ describe("adding elements to frames", () => {
// ); // );
}); });
it("should add arrow bound with text when frame is in a layer below", async () => { it.skip("should add arrow bound with text when frame is in a layer below", async () => {
h.elements = [frame, arrow, text]; h.elements = [frame, arrow, text];
resizeFrameOverElement(frame, arrow); resizeFrameOverElement(frame, arrow);
@@ -359,7 +359,7 @@ describe("adding elements to frames", () => {
expectEqualIds([arrow, text, frame]); expectEqualIds([arrow, text, frame]);
}); });
it("should add arrow bound with text when frame is in an inner layer", async () => { it.skip("should add arrow bound with text when frame is in an inner layer", async () => {
h.elements = [arrow, frame, text]; h.elements = [arrow, frame, text];
resizeFrameOverElement(frame, arrow); resizeFrameOverElement(frame, arrow);
@@ -371,7 +371,7 @@ describe("adding elements to frames", () => {
}); });
describe("resizing frame over elements but downwards", async () => { describe("resizing frame over elements but downwards", async () => {
it("should add elements when frame is in a layer below", async () => { it.skip("should add elements when frame is in a layer below", async () => {
h.elements = [frame, rect1, rect2, rect3, rect4]; h.elements = [frame, rect1, rect2, rect3, rect4];
resizeFrameOverElement(frame, rect4); resizeFrameOverElement(frame, rect4);
@@ -382,7 +382,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, rect3, frame, rect4, rect1]); expectEqualIds([rect2, rect3, frame, rect4, rect1]);
}); });
it("should add elements when frame is in a layer above", async () => { it.skip("should add elements when frame is in a layer above", async () => {
h.elements = [rect1, rect2, rect3, rect4, frame]; h.elements = [rect1, rect2, rect3, rect4, frame];
resizeFrameOverElement(frame, rect4); resizeFrameOverElement(frame, rect4);
@@ -393,7 +393,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]); expectEqualIds([rect1, rect2, rect3, frame, rect4]);
}); });
it("should add elements when frame is in an inner layer", async () => { it.skip("should add elements when frame is in an inner layer", async () => {
h.elements = [rect1, rect2, frame, rect3, rect4]; h.elements = [rect1, rect2, frame, rect3, rect4];
resizeFrameOverElement(frame, rect4); resizeFrameOverElement(frame, rect4);
@@ -408,7 +408,7 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => { describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame); await commonTestCases(dragElementIntoFrame);
it("should drag element inside, duplicate it and keep it in frame", () => { it.skip("should drag element inside, duplicate it and keep it in frame", () => {
h.elements = [frame, rect2]; h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
@@ -422,7 +422,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2_copy, rect2, frame]); expectEqualIds([rect2_copy, rect2, frame]);
}); });
it("should drag element inside, duplicate it and remove it from frame", () => { it.skip("should drag element inside, duplicate it and remove it from frame", () => {
h.elements = [frame, rect2]; h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
+143 -293
View File
@@ -1,8 +1,4 @@
import { import { getCommonBounds, getElementBounds, isTextElement } from "./element";
getCommonBounds,
getElementAbsoluteCoords,
isTextElement,
} from "./element";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameElement,
@@ -19,10 +15,10 @@ import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types"; import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element"; import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds"; import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect } from "./packages/utils";
// --------------------------- Frame State ------------------------------------ // --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = ( export const bindElementsToFramesAfterDuplication = (
@@ -57,129 +53,21 @@ export const bindElementsToFramesAfterDuplication = (
}; };
// --------------------------- Frame Geometry --------------------------------- // --------------------------- Frame Geometry ---------------------------------
class Point { export function isElementIntersectingFrame(
x: number; element: ExcalidrawElement,
y: number; frame: ExcalidrawFrameElement,
) {
const frameLineSegments = getElementLineSegments(frame);
constructor(x: number, y: number) { const elementLineSegments = getElementLineSegments(element);
this.x = x;
this.y = y;
}
}
class LineSegment { const intersecting = frameLineSegments.some((frameLineSegment) =>
first: Point; elementLineSegments.some((elementLineSegment) =>
second: Point; doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
),
);
constructor(pointA: Point, pointB: Point) { return intersecting;
this.first = pointA;
this.second = pointB;
}
public getBoundingBox(): [Point, Point] {
return [
new Point(
Math.min(this.first.x, this.second.x),
Math.min(this.first.y, this.second.y),
),
new Point(
Math.max(this.first.x, this.second.x),
Math.max(this.first.y, this.second.y),
),
];
}
}
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
class FrameGeometry {
private static EPSILON = 0.000001;
private static crossProduct(a: Point, b: Point) {
return a.x * b.y - b.x * a.y;
}
private static doBoundingBoxesIntersect(
a: [Point, Point],
b: [Point, Point],
) {
return (
a[0].x <= b[1].x &&
a[1].x >= b[0].x &&
a[0].y <= b[1].y &&
a[1].y >= b[0].y
);
}
private static isPointOnLine(a: LineSegment, b: Point) {
const aTmp = new LineSegment(
new Point(0, 0),
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
);
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
const r = this.crossProduct(aTmp.second, bTmp);
return Math.abs(r) < this.EPSILON;
}
private static isPointRightOfLine(a: LineSegment, b: Point) {
const aTmp = new LineSegment(
new Point(0, 0),
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
);
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
return this.crossProduct(aTmp.second, bTmp) < 0;
}
private static lineSegmentTouchesOrCrossesLine(
a: LineSegment,
b: LineSegment,
) {
return (
this.isPointOnLine(a, b.first) ||
this.isPointOnLine(a, b.second) ||
(this.isPointRightOfLine(a, b.first)
? !this.isPointRightOfLine(a, b.second)
: this.isPointRightOfLine(a, b.second))
);
}
private static doLineSegmentsIntersect(
a: [readonly [number, number], readonly [number, number]],
b: [readonly [number, number], readonly [number, number]],
) {
const aSegment = new LineSegment(
new Point(a[0][0], a[0][1]),
new Point(a[1][0], a[1][1]),
);
const bSegment = new LineSegment(
new Point(b[0][0], b[0][1]),
new Point(b[1][0], b[1][1]),
);
const box1 = aSegment.getBoundingBox();
const box2 = bSegment.getBoundingBox();
return (
this.doBoundingBoxesIntersect(box1, box2) &&
this.lineSegmentTouchesOrCrossesLine(aSegment, bSegment) &&
this.lineSegmentTouchesOrCrossesLine(bSegment, aSegment)
);
}
public static isElementIntersectingFrame(
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) {
const frameLineSegments = getElementLineSegments(frame);
const elementLineSegments = getElementLineSegments(element);
const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) =>
this.doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
),
);
return intersecting;
}
} }
export const getElementsCompletelyInFrame = ( export const getElementsCompletelyInFrame = (
@@ -194,39 +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) =>
FrameGeometry.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
); );
}; };
@@ -235,9 +111,12 @@ export const elementOverlapsWithFrame = (
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => { ) => {
return ( return (
elementsAreInFrameBounds([element], frame) || // frame contains element
FrameGeometry.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)
); );
}; };
@@ -248,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],
@@ -272,8 +151,8 @@ export const groupsAreAtLeastIntersectingTheFrame = (
return !!elementsInGroup.find( return !!elementsInGroup.find(
(element) => (element) =>
elementsAreInFrameBounds([element], frame) || elementsAreInBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame), isElementIntersectingFrame(element, frame),
); );
}; };
@@ -293,8 +172,8 @@ export const groupsAreCompletelyOutOfFrame = (
return ( return (
elementsInGroup.find( elementsInGroup.find(
(element) => (element) =>
elementsAreInFrameBounds([element], frame) || elementsAreInBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame), isElementIntersectingFrame(element, frame),
) === undefined ) === undefined
); );
}; };
@@ -313,33 +192,44 @@ 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,
opts?: { includeBoundArrows?: boolean }, ) => 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,
) => { ) => {
return allElements.filter((element) => { const frameElements = arrayToMap(getFrameElements(allElements));
if (element.frameId === frameId) { return allElements.filter(
return true; (element) =>
} frameElements.has(element.id) ||
if (opts?.includeBoundArrows && element.type === "arrow") { !element.frameId ||
const bindingId = element.startBinding?.elementId; !frameElements.has(element.frameId),
if (bindingId) { );
const boundElement = Scene.getScene(element)?.getElement(bindingId);
if (boundElement?.frameId === frameId) {
return true;
}
}
}
return false;
});
}; };
export const getElementsInResizingFrame = ( export const getElementsInResizingFrame = (
@@ -347,15 +237,21 @@ export const getElementsInResizingFrame = (
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),
@@ -371,7 +267,7 @@ export const getElementsInResizingFrame = (
); );
for (const element of elementsNotCompletelyInFrame) { for (const element of elementsNotCompletelyInFrame) {
if (!FrameGeometry.isElementIntersectingFrame(element, frame)) { if (!isElementIntersectingFrame(element, frame)) {
if (element.groupIds.length === 0) { if (element.groupIds.length === 0) {
nextElementsInFrame.delete(element); nextElementsInFrame.delete(element);
} }
@@ -422,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);
} }
@@ -468,18 +364,10 @@ export const getContainingFrame = (
return null; return null;
}; };
export const isValidFrameChild = (element: ExcalidrawElement) => {
return (
element.type !== "frame" &&
// arrows that are bound to elements cannot be frame children
(element.type !== "arrow" || (!element.startBinding && !element.endBinding))
);
};
// --------------------------- 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]
*/ */
@@ -488,20 +376,17 @@ export const addElementsToFrame = (
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => { ) => {
const { allElementsIndexMap, currTargetFrameChildrenMap } = const { currTargetFrameChildrenMap } = allElements.reduce(
allElements.reduce( (acc, element, index) => {
(acc, element, index) => { if (element.frameId === frame.id) {
acc.allElementsIndexMap.set(element.id, index); acc.currTargetFrameChildrenMap.set(element.id, true);
if (element.frameId === frame.id) { }
acc.currTargetFrameChildrenMap.set(element.id, true); return acc;
} },
return acc; {
}, currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
{ },
allElementsIndexMap: new Map<ExcalidrawElement["id"], number>(), );
currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
},
);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id)); const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
@@ -514,9 +399,6 @@ export const addElementsToFrame = (
elementsToAdd, elementsToAdd,
)) { )) {
if (!currTargetFrameChildrenMap.has(element.id)) { if (!currTargetFrameChildrenMap.has(element.id)) {
if (!isValidFrameChild(element)) {
continue;
}
finalElementsToAdd.push(element); finalElementsToAdd.push(element);
} }
@@ -530,66 +412,6 @@ export const addElementsToFrame = (
} }
} }
const finalElementsToAddSet = new Set(finalElementsToAdd.map((el) => el.id));
const nextElements: ExcalidrawElement[] = [];
const processedElements = new Set<ExcalidrawElement["id"]>();
for (const element of allElements) {
if (processedElements.has(element.id)) {
continue;
}
processedElements.add(element.id);
if (
finalElementsToAddSet.has(element.id) ||
(element.frameId && element.frameId === frame.id)
) {
// will be added in bulk once we process target frame
continue;
}
// target frame
if (element.id === frame.id) {
const currFrameChildren = getFrameElements(allElements, frame.id);
currFrameChildren.forEach((child) => {
processedElements.add(child.id);
});
// if not found, add all children on top by assigning the lowest index
const targetFrameIndex = allElementsIndexMap.get(frame.id) ?? -1;
const { newChildren_left, newChildren_right } = finalElementsToAdd.reduce(
(acc, element) => {
// if index not found, add on top of current frame children
const elementIndex = allElementsIndexMap.get(element.id) ?? Infinity;
if (elementIndex < targetFrameIndex) {
acc.newChildren_left.push(element);
} else {
acc.newChildren_right.push(element);
}
return acc;
},
{
newChildren_left: [] as ExcalidrawElement[],
newChildren_right: [] as ExcalidrawElement[],
},
);
nextElements.push(
...newChildren_left,
...currFrameChildren,
...newChildren_right,
element,
);
continue;
}
nextElements.push(element);
}
for (const element of finalElementsToAdd) { for (const element of finalElementsToAdd) {
mutateElement( mutateElement(
element, element,
@@ -599,8 +421,7 @@ export const addElementsToFrame = (
false, false,
); );
} }
return allElements.slice();
return nextElements;
}; };
export const removeElementsFromFrame = ( export const removeElementsFromFrame = (
@@ -608,20 +429,23 @@ export const removeElementsFromFrame = (
elementsToRemove: NonDeletedExcalidrawElement[], elementsToRemove: NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const _elementsToRemove: ExcalidrawElement[] = []; const _elementsToRemove = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
>();
for (const element of elementsToRemove) { for (const element of elementsToRemove) {
if (element.frameId) { if (element.frameId) {
_elementsToRemove.push(element); _elementsToRemove.set(element.id, element);
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
_elementsToRemove.push(boundTextElement); _elementsToRemove.set(boundTextElement.id, boundTextElement);
} }
} }
} }
for (const element of _elementsToRemove) { for (const [, element] of _elementsToRemove) {
mutateElement( mutateElement(
element, element,
{ {
@@ -631,13 +455,7 @@ export const removeElementsFromFrame = (
); );
} }
const nextElements = moveOneRight( return allElements.slice();
allElements,
appState,
Array.from(_elementsToRemove),
);
return nextElements;
}; };
export const removeAllElementsFromFrame = ( export const removeAllElementsFromFrame = (
@@ -645,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);
}; };
@@ -688,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);
} }
@@ -755,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;
} }
@@ -783,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) {
@@ -805,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
View File
@@ -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);
}
} }
} }
+7 -10
View File
@@ -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,16 +10,17 @@ 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();
useLayoutEffect(() => { useLayoutEffect(() => {
if (div) { if (div) {
div.classList.toggle("excalidraw--mobile", device.isMobile); div.className = "";
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
div.classList.toggle("excalidraw--mobile", device.editor.isMobile);
div.classList.toggle("theme--dark", theme === "dark");
} }
}, [div, device.isMobile]); }, [div, theme, device.editor.isMobile, opts?.className]);
useLayoutEffect(() => { useLayoutEffect(() => {
const container = opts?.parentSelector const container = opts?.parentSelector
@@ -32,10 +33,6 @@ export const useCreatePortalContainer = (opts?: {
const div = document.createElement("div"); const div = document.createElement("div");
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
div.classList.toggle("theme--dark", theme === "dark");
container.appendChild(div); container.appendChild(div);
setDiv(div); setDiv(div);
@@ -43,7 +40,7 @@ export const useCreatePortalContainer = (opts?: {
return () => { return () => {
container.removeChild(div); container.removeChild(div);
}; };
}, [excalidrawContainer, theme, opts?.className, opts?.parentSelector]); }, [excalidrawContainer, opts?.parentSelector]);
return div; return div;
}; };
+13 -2
View File
@@ -218,7 +218,10 @@
"libraryElementTypeError": { "libraryElementTypeError": {
"embeddable": "Embeddable elements cannot be added to the library.", "embeddable": "Embeddable elements cannot be added to the library.",
"image": "Support for adding images to the library coming soon!" "image": "Support for adding images to the library coming soon!"
} },
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
"asyncPasteFailedOnParse": "Couldn't paste.",
"copyToSystemClipboardFailed": "Couldn't copy to clipboard."
}, },
"toolBar": { "toolBar": {
"selection": "Selection", "selection": "Selection",
@@ -239,7 +242,8 @@
"embeddable": "Web Embed", "embeddable": "Web Embed",
"laser": "Laser pointer", "laser": "Laser pointer",
"hand": "Hand (panning tool)", "hand": "Hand (panning tool)",
"extraTools": "More tools" "extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw"
}, },
"headings": { "headings": {
"canvasActions": "Canvas actions", "canvasActions": "Canvas actions",
@@ -498,5 +502,12 @@
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below." "description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
} }
} }
},
"mermaid": {
"title": "Mermaid to Excalidraw",
"button": "Insert",
"description": "Currently only <flowchartLink>Flowcharts</flowchartLink> and <sequenceLink>Sequence Diagrams</sequenceLink> are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax",
"preview": "Preview"
} }
} }
+4
View File
@@ -505,3 +505,7 @@ export const rangeIntersection = (
return null; return null;
}; };
export const isValueInRange = (value: number, min: number, max: number) => {
return value >= min && value <= max;
};
+65
View File
@@ -0,0 +1,65 @@
import { Bounds } from "../element/bounds";
import { Point } from "../types";
export type LineSegment = [Point, Point];
export function getBBox(line: LineSegment): Bounds {
return [
Math.min(line[0][0], line[1][0]),
Math.min(line[0][1], line[1][1]),
Math.max(line[0][0], line[1][0]),
Math.max(line[0][1], line[1][1]),
];
}
export function crossProduct(a: Point, b: Point) {
return a[0] * b[1] - b[0] * a[1];
}
export function doBBoxesIntersect(a: Bounds, b: Bounds) {
return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
}
export function translate(a: Point, b: Point): Point {
return [a[0] - b[0], a[1] - b[1]];
}
const EPSILON = 0.000001;
export function isPointOnLine(l: LineSegment, p: Point) {
const p1 = translate(l[1], l[0]);
const p2 = translate(p, l[0]);
const r = crossProduct(p1, p2);
return Math.abs(r) < EPSILON;
}
export function isPointRightOfLine(l: LineSegment, p: Point) {
const p1 = translate(l[1], l[0]);
const p2 = translate(p, l[0]);
return crossProduct(p1, p2) < 0;
}
export function isLineSegmentTouchingOrCrossingLine(
a: LineSegment,
b: LineSegment,
) {
return (
isPointOnLine(a, b[0]) ||
isPointOnLine(a, b[1]) ||
(isPointRightOfLine(a, b[0])
? !isPointRightOfLine(a, b[1])
: isPointRightOfLine(a, b[1]))
);
}
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
export function doLineSegmentsIntersect(a: LineSegment, b: LineSegment) {
return (
doBBoxesIntersect(getBBox(a), getBBox(b)) &&
isLineSegmentTouchingOrCrossingLine(a, b) &&
isLineSegmentTouchingOrCrossingLine(b, a)
);
}
+3 -3
View File
@@ -1,16 +1,16 @@
[ [
{ {
"path": "dist/excalidraw.production.min.js", "path": "dist/excalidraw.production.min.js",
"limit": "305 kB" "limit": "325 kB"
}, },
{ {
"path": "dist/excalidraw-assets/locales", "path": "dist/excalidraw-assets/locales",
"name": "dist/excalidraw-assets/locales", "name": "dist/excalidraw-assets/locales",
"limit": "270 kB" "limit": "290 kB"
}, },
{ {
"path": "dist/excalidraw-assets/vendor-*.js", "path": "dist/excalidraw-assets/vendor-*.js",
"name": "dist/excalidraw-assets/vendor*.js", "name": "dist/excalidraw-assets/vendor*.js",
"limit": "30 kB" "limit": "900 kB"
} }
] ]
+21 -1
View File
@@ -15,7 +15,27 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [7078](https://github.com/excalidraw/excalidraw/pull/7078) - 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)
- 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)
#### 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)
+3 -1
View File
@@ -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} />
@@ -7,6 +7,7 @@ const elements: ExcalidrawElementSkeleton[] = [
x: 10, x: 10,
y: 10, y: 10,
strokeWidth: 2, strokeWidth: 2,
id: "1",
}, },
{ {
type: "diamond", type: "diamond",
@@ -19,6 +20,7 @@ const elements: ExcalidrawElementSkeleton[] = [
strokeColor: "#099268", strokeColor: "#099268",
fontSize: 30, fontSize: 30,
}, },
id: "2",
}, },
{ {
type: "arrow", type: "arrow",
@@ -36,6 +38,11 @@ const elements: ExcalidrawElementSkeleton[] = [
height: 230, height: 230,
fileId: "rocket" as FileId, fileId: "rocket" as FileId,
}, },
{
type: "frame",
children: ["1", "2"],
name: "My frame",
},
]; ];
export default { export default {
elements, elements,
+13 -16
View File
@@ -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,3 +244,10 @@ 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 {
elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "../withinBounds";
@@ -41,6 +41,14 @@ module.exports = {
"sass-loader", "sass-loader",
], ],
}, },
// So that type module works with webpack
// https://github.com/webpack/webpack/issues/11467#issuecomment-691873586
{
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
},
{ {
test: /\.(ts|tsx|js|jsx|mjs)$/, test: /\.(ts|tsx|js|jsx|mjs)$/,
exclude: exclude:
@@ -44,6 +44,14 @@ module.exports = {
"sass-loader", "sass-loader",
], ],
}, },
// So that type module works with webpack
// https://github.com/webpack/webpack/issues/11467#issuecomment-691873586
{
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
},
{ {
test: /\.(ts|tsx|js|jsx|mjs)$/, test: /\.(ts|tsx|js|jsx|mjs)$/,
exclude: exclude:
+22 -39
View File
@@ -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 (
@@ -229,6 +206,12 @@ export const exportToClipboard = async (
} }
}; };
export * from "./bbox";
export {
elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "./withinBounds";
export { serializeAsJSON, serializeLibraryAsJSON } from "../data/json"; export { serializeAsJSON, serializeLibraryAsJSON } from "../data/json";
export { export {
loadFromBlob, loadFromBlob,
+262
View File
@@ -0,0 +1,262 @@
import { Bounds } from "../element/bounds";
import { API } from "../tests/helpers/api";
import {
elementPartiallyOverlapsWithOrContainsBBox,
elementsOverlappingBBox,
isElementInsideBBox,
} from "./withinBounds";
const makeElement = (x: number, y: number, width: number, height: number) =>
API.createElement({
type: "rectangle",
x,
y,
width,
height,
});
const makeBBox = (
minX: number,
minY: number,
maxX: number,
maxY: number,
): Bounds => [minX, minY, maxX, maxY];
describe("isElementInsideBBox()", () => {
it("should return true if element is fully inside", () => {
const bbox = makeBBox(0, 0, 100, 100);
// bbox contains element
expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
});
it("should return false if element is only partially overlapping", () => {
const bbox = makeBBox(0, 0, 100, 100);
// element contains bbox
expect(isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox)).toBe(
false,
);
// element overlaps bbox from top-left
expect(isElementInsideBBox(makeElement(-10, -10, 100, 100), bbox)).toBe(
false,
);
// element overlaps bbox from top-right
expect(isElementInsideBBox(makeElement(90, -10, 100, 100), bbox)).toBe(
false,
);
// element overlaps bbox from bottom-left
expect(isElementInsideBBox(makeElement(-10, 90, 100, 100), bbox)).toBe(
false,
);
// element overlaps bbox from bottom-right
expect(isElementInsideBBox(makeElement(90, 90, 100, 100), bbox)).toBe(
false,
);
});
it("should return false if element outside", () => {
const bbox = makeBBox(0, 0, 100, 100);
// outside diagonally
expect(isElementInsideBBox(makeElement(110, 110, 100, 100), bbox)).toBe(
false,
);
// outside on the left
expect(isElementInsideBBox(makeElement(-110, 10, 50, 50), bbox)).toBe(
false,
);
// outside on the right
expect(isElementInsideBBox(makeElement(110, 10, 50, 50), bbox)).toBe(false);
// outside on the top
expect(isElementInsideBBox(makeElement(10, -110, 50, 50), bbox)).toBe(
false,
);
// outside on the bottom
expect(isElementInsideBBox(makeElement(10, 110, 50, 50), bbox)).toBe(false);
});
it("should return true if bbox contains element and flag enabled", () => {
const bbox = makeBBox(0, 0, 100, 100);
// element contains bbox
expect(
isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox, true),
).toBe(true);
// bbox contains element
expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
});
});
describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
it("should return true if element overlaps, is inside, or contains", () => {
const bbox = makeBBox(0, 0, 100, 100);
// bbox contains element
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(0, 0, 100, 100),
bbox,
),
).toBe(true);
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(10, 10, 90, 90),
bbox,
),
).toBe(true);
// element contains bbox
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(-10, -10, 110, 110),
bbox,
),
).toBe(true);
// element overlaps bbox from top-left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(-10, -10, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from top-right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(90, -10, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from bottom-left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(-10, 90, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from bottom-right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(90, 90, 100, 100),
bbox,
),
).toBe(true);
});
it("should return false if element does not overlap", () => {
const bbox = makeBBox(0, 0, 100, 100);
// outside diagonally
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(110, 110, 100, 100),
bbox,
),
).toBe(false);
// outside on the left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(-110, 10, 50, 50),
bbox,
),
).toBe(false);
// outside on the right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(110, 10, 50, 50),
bbox,
),
).toBe(false);
// outside on the top
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(10, -110, 50, 50),
bbox,
),
).toBe(false);
// outside on the bottom
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(10, 110, 50, 50),
bbox,
),
).toBe(false);
});
});
describe("elementsOverlappingBBox()", () => {
it("should return elements that overlap bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 90, 90);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "overlap",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectContainingBBox, rectOverlappingTopLeft]);
});
it("should return elements inside/containing bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 90, 90);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "contain",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectContainingBBox]);
});
it("should return elements inside bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 90, 90);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "inside",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside]);
});
// TODO test linear, freedraw, and diamond element types (+rotated)
});
+210
View File
@@ -0,0 +1,210 @@
import type {
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
isArrowElement,
isExcalidrawElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import { isValueInRange, rotatePoint } from "../math";
import type { Point } from "../types";
import { Bounds, getElementBounds } from "../element/bounds";
type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[];
type Points = readonly Point[];
/** @returns vertices relative to element's top-left [0,0] position */
const getNonLinearElementRelativePoints = (
element: Exclude<
Element,
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
>,
): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => {
if (element.type === "diamond") {
return [
[element.width / 2, 0],
[element.width, element.height / 2],
[element.width / 2, element.height],
[0, element.height / 2],
];
}
return [
[0, 0],
[0 + element.width, 0],
[0 + element.width, element.height],
[0, element.height],
];
};
/** @returns vertices relative to element's top-left [0,0] position */
const getElementRelativePoints = (element: ExcalidrawElement): Points => {
if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points;
}
return getNonLinearElementRelativePoints(element);
};
const getMinMaxPoints = (points: Points) => {
const ret = points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, y);
return limits;
},
{
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
cx: 0,
cy: 0,
},
);
ret.cx = (ret.maxX + ret.minX) / 2;
ret.cy = (ret.maxY + ret.minY) / 2;
return ret;
};
const getRotatedBBox = (element: Element): Bounds => {
const points = getElementRelativePoints(element);
const { cx, cy } = getMinMaxPoints(points);
const centerPoint: Point = [cx, cy];
const rotatedPoints = points.map((point) =>
rotatePoint([point[0], point[1]], centerPoint, element.angle),
);
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
};
export const isElementInsideBBox = (
element: Element,
bbox: Bounds,
eitherDirection = false,
): boolean => {
const elementBBox = getRotatedBBox(element);
const elementInsideBbox =
bbox[0] <= elementBBox[0] &&
bbox[2] >= elementBBox[2] &&
bbox[1] <= elementBBox[1] &&
bbox[3] >= elementBBox[3];
if (!eitherDirection) {
return elementInsideBbox;
}
if (elementInsideBbox) {
return true;
}
return (
elementBBox[0] <= bbox[0] &&
elementBBox[2] >= bbox[2] &&
elementBBox[1] <= bbox[1] &&
elementBBox[3] >= bbox[3]
);
};
export const elementPartiallyOverlapsWithOrContainsBBox = (
element: Element,
bbox: Bounds,
): boolean => {
const elementBBox = getRotatedBBox(element);
return (
(isValueInRange(elementBBox[0], bbox[0], bbox[2]) ||
isValueInRange(bbox[0], elementBBox[0], elementBBox[2])) &&
(isValueInRange(elementBBox[1], bbox[1], bbox[3]) ||
isValueInRange(bbox[1], elementBBox[1], elementBBox[3]))
);
};
export const elementsOverlappingBBox = ({
elements,
bounds,
type,
errorMargin = 0,
}: {
elements: Elements;
bounds: Bounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */
errorMargin?: number;
/**
* - overlap: elements overlapping or inside bounds
* - contain: elements inside bounds or bounds inside elements
* - inside: elements inside bounds
**/
type: "overlap" | "contain" | "inside";
}) => {
if (isExcalidrawElement(bounds)) {
bounds = getElementBounds(bounds);
}
const adjustedBBox: Bounds = [
bounds[0] - errorMargin,
bounds[1] - errorMargin,
bounds[2] + errorMargin,
bounds[3] + errorMargin,
];
const includedElementSet = new Set<string>();
for (const element of elements) {
if (includedElementSet.has(element.id)) {
continue;
}
const isOverlaping =
type === "overlap"
? elementPartiallyOverlapsWithOrContainsBBox(element, adjustedBBox)
: type === "inside"
? isElementInsideBBox(element, adjustedBBox)
: isElementInsideBBox(element, adjustedBBox, true);
if (isOverlaping) {
includedElementSet.add(element.id);
if (element.boundElements) {
for (const boundElement of element.boundElements) {
includedElementSet.add(boundElement.id);
}
}
if (isTextElement(element) && element.containerId) {
includedElementSet.add(element.containerId);
}
if (isArrowElement(element)) {
if (element.startBinding) {
includedElementSet.add(element.startBinding.elementId);
}
if (element.endBinding) {
includedElementSet.add(element.endBinding?.elementId);
}
}
}
}
return elements.filter((element) => includedElementSet.has(element.id));
};
+73 -30
View File
@@ -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}`);
+133 -204
View File
@@ -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 {
@@ -69,17 +69,17 @@ import {
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { renderSnaps } from "./renderSnaps"; import { renderSnaps } from "./renderSnaps";
import { import {
isArrowElement,
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";
@@ -166,21 +166,6 @@ const fillCircle = (
} }
}; };
const fillSquare = (
context: CanvasRenderingContext2D,
cx: number,
cy: number,
side: number,
stroke = true,
) => {
context.beginPath();
context.rect(cx - side / 2, cy - side / 2, side, side);
context.fill();
if (stroke) {
context.stroke();
}
};
const strokeGrid = ( const strokeGrid = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
gridSize: number, gridSize: number,
@@ -239,7 +224,6 @@ const renderSingleLinearPoint = (
point: Point, point: Point,
radius: number, radius: number,
isSelected: boolean, isSelected: boolean,
renderAsSquare: boolean,
isPhantomPoint = false, isPhantomPoint = false,
) => { ) => {
context.strokeStyle = "#5e5ad8"; context.strokeStyle = "#5e5ad8";
@@ -251,29 +235,13 @@ const renderSingleLinearPoint = (
context.fillStyle = "rgba(177, 151, 252, 0.7)"; context.fillStyle = "rgba(177, 151, 252, 0.7)";
} }
const effectiveRadius = radius / appState.zoom.value; fillCircle(
context,
if (renderAsSquare) { point[0],
fillSquare( point[1],
context, radius / appState.zoom.value,
point[0], !isPhantomPoint,
point[1], );
effectiveRadius * 2,
!isPhantomPoint,
);
} else {
fillCircle(context, point[0], point[1], effectiveRadius, !isPhantomPoint);
}
};
const isLinearPointAtIndexSquared = (
element: NonDeleted<ExcalidrawLinearElement>,
index: number,
) => {
const splitting = element.segmentSplitIndices
? element.segmentSplitIndices.includes(index)
: false;
return element.roundness ? splitting : !splitting;
}; };
const renderLinearPointHandles = ( const renderLinearPointHandles = (
@@ -297,14 +265,7 @@ const renderLinearPointHandles = (
const isSelected = const isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
renderSingleLinearPoint( renderSingleLinearPoint(context, appState, point, radius, isSelected);
context,
appState,
point,
radius,
isSelected,
isLinearPointAtIndexSquared(element, idx),
);
}); });
//Rendering segment mid points //Rendering segment mid points
@@ -332,7 +293,6 @@ const renderLinearPointHandles = (
segmentMidPoint, segmentMidPoint,
radius, radius,
false, false,
false,
); );
highlightPoint(segmentMidPoint, context, appState); highlightPoint(segmentMidPoint, context, appState);
} else { } else {
@@ -343,7 +303,6 @@ const renderLinearPointHandles = (
segmentMidPoint, segmentMidPoint,
radius, radius,
false, false,
false,
); );
} }
} else if (appState.editingLinearElement || points.length === 2) { } else if (appState.editingLinearElement || points.length === 2) {
@@ -353,7 +312,6 @@ const renderLinearPointHandles = (
segmentMidPoint, segmentMidPoint,
POINT_HANDLE_SIZE / 2, POINT_HANDLE_SIZE / 2,
false, false,
false,
true, true,
); );
} }
@@ -366,16 +324,16 @@ const highlightPoint = (
point: Point, point: Point,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
renderAsSquare = false,
) => { ) => {
context.fillStyle = "rgba(105, 101, 219, 0.4)"; context.fillStyle = "rgba(105, 101, 219, 0.4)";
const radius = LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
if (renderAsSquare) { fillCircle(
fillSquare(context, point[0], point[1], radius * 2, false); context,
} else { point[0],
fillCircle(context, point[0], point[1], radius, false); point[1],
} LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value,
false,
);
}; };
const renderLinearElementPointHighlight = ( const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@@ -397,15 +355,10 @@ const renderLinearElementPointHighlight = (
element, element,
hoverPointIndex, hoverPointIndex,
); );
context.save(); context.save();
context.translate(appState.scrollX, appState.scrollY); context.translate(appState.scrollX, appState.scrollY);
highlightPoint(
point, highlightPoint(point, context, appState);
context,
appState,
isLinearPointAtIndexSquared(element, hoverPointIndex),
);
context.restore(); context.restore();
}; };
@@ -417,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,
@@ -993,112 +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,
// do not clip arrows isFreeDrawElement(element)
if (!isArrowElement(element)) { ? element.strokeWidth * 8
frameClip(frame, context, renderConfig, appState); : element.roughness * (isLinearElement(element) ? 8 : 4),
} ) &&
} isElementInFrame(element, elements, appState, {
renderElement(element, rc, context, renderConfig, appState); targetFrame,
context.restore(); processedGroupIds,
} else { })
renderElement(element, rc, context, renderConfig, appState); ) {
} context.save();
if (!isExporting) { frameClip(targetFrame, context, renderConfig, appState);
renderLinkIcon(element, context, appState); _renderElement(element);
} context.restore();
} catch (error: any) { } else {
console.error(error); _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 */
@@ -1203,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;
@@ -1349,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);
@@ -1368,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,
@@ -1381,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!),
); );
}; };
@@ -1505,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 {
@@ -1533,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);
@@ -1556,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
View File
@@ -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();
+26 -59
View File
@@ -14,19 +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 { Point } from "../types"; 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 = (
@@ -55,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,
@@ -229,44 +241,18 @@ export const _generateElementShape = (
// points array can be empty in the beginning, so it is important to add // points array can be empty in the beginning, so it is important to add
// initial position to it // initial position to it
const points = element.points.length const points = element.points.length ? element.points : [[0, 0]];
? element.points
: ([[0, 0]] as Point[]);
// curve is always the first element // curve is always the first element
// this simplifies finding the curve for an element // this simplifies finding the curve for an element
const splits = element.segmentSplitIndices || [];
if (!element.roundness) { if (!element.roundness) {
if (splits.length === 0) { if (options.fill) {
if (options.fill) { shape = [generator.polygon(points as [number, number][], options)];
shape = [generator.polygon(points as [number, number][], options)];
} else {
shape = [
generator.linearPath(points as [number, number][], options),
];
}
} else { } else {
const splitInverse: number[] = []; shape = [generator.linearPath(points as [number, number][], options)];
const splitSet = new Set(splits);
for (let i = 0; i < points.length; i++) {
if (!splitSet.has(i)) {
splitInverse.push(i);
}
}
shape = [
generator.curve(
computeMultipleCurvesFromSplits(points, splitInverse),
options,
),
];
} }
} else { } else {
shape = [ shape = [generator.curve(points as [number, number][], options)];
generator.curve(
computeMultipleCurvesFromSplits(points, splits),
options,
),
];
} }
// add lines only in arrow // add lines only in arrow
@@ -403,22 +389,3 @@ export const _generateElementShape = (
} }
} }
}; };
const computeMultipleCurvesFromSplits = (
points: readonly Point[],
splits: readonly number[],
): [number, number][][] => {
const pointList: Point[][] = [];
let currentIndex = 0;
for (const index of splits) {
const slice = points.slice(currentIndex, index + 1);
if (slice.length) {
pointList.push([...slice]);
}
currentIndex = index;
}
if (currentIndex < points.length - 1) {
pointList.push(points.slice(currentIndex));
}
return pointList as [number, number][][];
};
-2
View File
@@ -39,8 +39,6 @@ export const canChangeRoundness = (type: string) =>
type === "line" || type === "line" ||
type === "diamond"; type === "diamond";
export const hasText = (type: string) => type === "text";
export const canHaveArrowheads = (type: string) => type === "arrow"; export const canHaveArrowheads = (type: string) => type === "arrow";
export const getElementAtPosition = ( export const getElementAtPosition = (

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