Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 348912f32f |
@@ -25,9 +25,6 @@
|
||||
<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" />
|
||||
</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">
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
|
||||
</a>
|
||||
|
||||
@@ -34,7 +34,7 @@ Open the `Menu` in the below playground and you will see the `custom footer` ren
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
if (device.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
|
||||
@@ -299,7 +299,7 @@ Open the `main menu` in the below example to view the footer.
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
if (device.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
@@ -335,6 +335,7 @@ The `device` has the following `attributes`
|
||||
|
||||
| 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` |
|
||||
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
|
||||
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
|
||||
|
||||
@@ -34,44 +34,19 @@ function App() {
|
||||
|
||||
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
|
||||
|
||||
Here are two ways on how you can render **Excalidraw** on **Next.js**.
|
||||
|
||||
1. Importing Excalidraw once **client** is rendered.
|
||||
The following workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
|
||||
|
||||
```jsx showLineNumbers
|
||||
import { useState, useEffect } from "react";
|
||||
export default function App() {
|
||||
const [Excalidraw, setExcalidraw] = useState(null);
|
||||
useEffect(() => {
|
||||
import("@excalidraw/excalidraw").then((comp) =>
|
||||
setExcalidraw(comp.Excalidraw),
|
||||
);
|
||||
import("@excalidraw/excalidraw").then((comp) => setExcalidraw(comp.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)
|
||||
|
||||
## Browser
|
||||
|
||||
@@ -19,4 +19,4 @@ Frames should be ordered where frame children come first, followed by the frame
|
||||
]
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* - DataState refers to full state of the app: appState, elements, images,
|
||||
* though some state is saved separately (collab username, library) for one
|
||||
* reason or another. We also save different data to different storage
|
||||
* reason or another. We also save different data to different sotrage
|
||||
* (localStorage, indexedDB).
|
||||
*/
|
||||
|
||||
|
||||
@@ -131,5 +131,5 @@ export class Debug {
|
||||
};
|
||||
};
|
||||
}
|
||||
//@ts-ignore
|
||||
|
||||
window.debug = Debug;
|
||||
|
||||
@@ -691,7 +691,7 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
ref={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
|
||||
@@ -17,10 +17,8 @@ describe("Test MobileMenu", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
//@ts-ignore
|
||||
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -30,15 +28,11 @@ describe("Test MobileMenu", () => {
|
||||
it("should set device correctly", () => {
|
||||
expect(h.app.device).toMatchInlineSnapshot(`
|
||||
{
|
||||
"editor": {
|
||||
"canFitSidebar": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
"canDeviceFitSidebar": false,
|
||||
"isLandscape": true,
|
||||
"isMobile": true,
|
||||
"isSmScreen": false,
|
||||
"isTouchScreen": false,
|
||||
"viewport": {
|
||||
"isLandscape": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "../../excalidraw-app/collab/reconciliation";
|
||||
import { randomInteger } from "../../src/random";
|
||||
import { AppState } from "../../src/types";
|
||||
import { cloneJSON } from "../../src/utils";
|
||||
|
||||
type Id = string;
|
||||
type ElementLike = {
|
||||
@@ -94,6 +93,8 @@ const cleanElements = (elements: ReconciledElements) => {
|
||||
});
|
||||
};
|
||||
|
||||
const cloneDeep = (data: any) => JSON.parse(JSON.stringify(data));
|
||||
|
||||
const test = <U extends `${string}:${"L" | "R"}`>(
|
||||
local: (Id | ElementLike)[],
|
||||
remote: (Id | ElementLike)[],
|
||||
@@ -114,15 +115,15 @@ const test = <U extends `${string}:${"L" | "R"}`>(
|
||||
"remote reconciliation",
|
||||
);
|
||||
|
||||
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
|
||||
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
|
||||
const __local = cleanElements(cloneDeep(_remote));
|
||||
const __remote = addParents(cleanElements(cloneDeep(remoteReconciled)));
|
||||
if (bidirectional) {
|
||||
try {
|
||||
expect(
|
||||
cleanElements(
|
||||
reconcileElements(
|
||||
cloneJSON(__local),
|
||||
cloneJSON(__remote),
|
||||
cloneDeep(__local),
|
||||
cloneDeep(__remote),
|
||||
{} as AppState,
|
||||
),
|
||||
),
|
||||
|
||||
+2
-3
@@ -21,8 +21,7 @@
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/laser-pointer": "1.2.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "0.1.2",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
"@sentry/browser": "6.2.5",
|
||||
@@ -126,7 +125,7 @@
|
||||
"test": "yarn test:app",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:coverage:watch": "vitest --coverage --watch",
|
||||
"test:ui": "yarn test --ui --coverage.enabled=true",
|
||||
"test:ui": "yarn test --ui",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease": "node scripts/prerelease.js",
|
||||
"build:preview": "yarn build && vite preview --port 5000",
|
||||
|
||||
@@ -438,6 +438,5 @@ export const actionToggleHandTool = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H,
|
||||
keyTest: (event) => event.key === KEYS.H,
|
||||
});
|
||||
|
||||
@@ -3,21 +3,19 @@ import { register } from "./register";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
createPasteEvent,
|
||||
probablySupportsClipboardBlob,
|
||||
probablySupportsClipboardWriteText,
|
||||
readSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
perform: async (elements, appState, _, app) => {
|
||||
const elementsToCopy = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
@@ -25,7 +23,7 @@ export const actionCopy = register({
|
||||
});
|
||||
|
||||
try {
|
||||
await copyToClipboard(elementsToCopy, app.files, event);
|
||||
await copyToClipboard(elementsToCopy, app.files);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@@ -49,50 +47,86 @@ export const actionPaste = register({
|
||||
name: "paste",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, data, app) => {
|
||||
let types;
|
||||
const MIME_TYPES: Record<string, string> = {};
|
||||
try {
|
||||
types = await readSystemClipboard();
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard?.read();
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
try {
|
||||
const blob = await item.getType(type);
|
||||
MIME_TYPES[type] = await blob.text();
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(MIME_TYPES).length === 0) {
|
||||
console.warn(
|
||||
"No clipboard data found from clipboard.read(). Falling back to clipboard.readText()",
|
||||
);
|
||||
// throw so we fall back onto clipboard.readText()
|
||||
throw new Error("No clipboard data found");
|
||||
}
|
||||
} catch (error: any) {
|
||||
try {
|
||||
MIME_TYPES["text/plain"] = await navigator.clipboard?.readText();
|
||||
} catch (error: any) {
|
||||
console.warn(`Cannot readText() from clipboard: ${error.message}`);
|
||||
if (isFirefox) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("hints.firefox_clipboard_write"),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} 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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.error(`actionPaste: ${error.message}`);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
app.pasteFromClipboard(createPasteEvent({ types }));
|
||||
console.log("actionPaste (1)", { MIME_TYPES });
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
for (const [type, value] of Object.entries(MIME_TYPES)) {
|
||||
try {
|
||||
event.clipboardData?.setData(type, value);
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`Cannot set ${type} as clipboardData item: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
event.clipboardData?.types.forEach((type) => {
|
||||
console.log(
|
||||
`actionPaste (2) event.clipboardData?.getData(${type})`,
|
||||
event.clipboardData?.getData(type),
|
||||
);
|
||||
});
|
||||
app.pasteFromClipboard(event);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -105,10 +139,13 @@ export const actionPaste = register({
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
actionCopy.perform(elements, appState, event, app);
|
||||
perform: (elements, appState, data, app) => {
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
},
|
||||
predicate: (elements, appState, appProps, app) => {
|
||||
return app.device.isMobile && !!navigator.clipboard;
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
});
|
||||
@@ -122,23 +159,20 @@ export const actionCopyAsSvg = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elements,
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard-svg",
|
||||
exportedElements,
|
||||
selectedElements.length
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
{
|
||||
...appState,
|
||||
exportingFrame,
|
||||
},
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@@ -174,17 +208,16 @@ export const actionCopyAsPng = register({
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elements,
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||
...appState,
|
||||
exportingFrame,
|
||||
});
|
||||
await exportCanvas(
|
||||
"clipboard",
|
||||
selectedElements.length
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.files,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
||||
@@ -25,7 +25,7 @@ import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameChildren,
|
||||
getFrameElements,
|
||||
} from "../frame";
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
@@ -155,7 +155,7 @@ const duplicateElements = (
|
||||
groupId,
|
||||
).flatMap((element) =>
|
||||
isFrameElement(element)
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
? [...getFrameElements(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
@@ -181,7 +181,7 @@ const duplicateElements = (
|
||||
continue;
|
||||
}
|
||||
if (isElementAFrame) {
|
||||
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||
const elementsInFrame = getFrameElements(sortedElements, element.id);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
|
||||
@@ -217,7 +217,7 @@ export const actionSaveFileToDisk = register({
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useDevice().editor.isMobile}
|
||||
showAriaLabel={useDevice().isMobile}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameChildren } from "../frame";
|
||||
import { getFrameElements } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { updateActiveTool } from "../utils";
|
||||
@@ -21,7 +21,7 @@ export const actionSelectAllElementsInFrame = register({
|
||||
const selectedFrame = app.scene.getSelectedElements(appState)[0];
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
const elementsInFrame = getFrameChildren(
|
||||
const elementsInFrame = getFrameElements(
|
||||
getNonDeletedElements(elements),
|
||||
selectedFrame.id,
|
||||
).filter((element) => !(element.type === "text" && element.containerId));
|
||||
|
||||
+13
-15
@@ -17,12 +17,15 @@ import {
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
getFrameElements,
|
||||
groupByFrames,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
@@ -187,6 +190,13 @@ export const actionUngroup = register({
|
||||
|
||||
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"][] = [];
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
@@ -211,19 +221,7 @@ export const actionUngroup = register({
|
||||
null,
|
||||
);
|
||||
|
||||
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) => {
|
||||
frames.forEach((frame) => {
|
||||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
@@ -51,6 +52,23 @@ 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({
|
||||
name: "toggleShortcuts",
|
||||
viewMode: true,
|
||||
|
||||
@@ -328,7 +328,7 @@ export const actionChangeFillStyle = register({
|
||||
trackEvent(
|
||||
"element",
|
||||
"changeFillStyle",
|
||||
`${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
|
||||
@@ -21,10 +21,8 @@ import {
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameElement,
|
||||
isArrowElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@@ -101,19 +99,16 @@ export const actionPasteStyles = register({
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
|
||||
DEFAULT_FONT_SIZE;
|
||||
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
|
||||
DEFAULT_FONT_FAMILY;
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
elementStylesToCopyFrom.lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
@@ -128,10 +123,7 @@ export const actionPasteStyles = register({
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
}
|
||||
|
||||
if (
|
||||
newElement.type === "arrow" &&
|
||||
isArrowElement(elementStylesToCopyFrom)
|
||||
) {
|
||||
if (newElement.type === "arrow") {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
|
||||
@@ -44,6 +44,7 @@ export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||
export {
|
||||
actionToggleCanvasMenu,
|
||||
actionToggleEditMenu,
|
||||
actionFullScreen,
|
||||
actionShortcuts,
|
||||
} from "./actionMenu";
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const trackAction = (
|
||||
trackEvent(
|
||||
action.trackEvent.category,
|
||||
action.trackEvent.action || action.name,
|
||||
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,10 +119,10 @@ export class ActionManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
executeAction<T extends Action>(
|
||||
action: T,
|
||||
executeAction(
|
||||
action: Action,
|
||||
source: ActionSource = "api",
|
||||
value: Parameters<T["perform"]>[2] = null,
|
||||
value: any = null,
|
||||
) {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
|
||||
+11
-185
@@ -1,196 +1,22 @@
|
||||
import {
|
||||
createPasteEvent,
|
||||
parseClipboard,
|
||||
serializeAsClipboardJSON,
|
||||
} from "./clipboard";
|
||||
import { API } from "./tests/helpers/api";
|
||||
import { parseClipboard } from "./clipboard";
|
||||
import { createPasteEvent } from "./tests/test-utils";
|
||||
|
||||
describe("parseClipboard()", () => {
|
||||
it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
|
||||
let text;
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
describe("Test parseClipboard", () => {
|
||||
it("should parse valid json correctly", async () => {
|
||||
let text = "123";
|
||||
|
||||
text = "123";
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
let clipboardData = await parseClipboard(
|
||||
createPasteEvent({ "text/plain": text }),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
text = "[123]";
|
||||
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
createPasteEvent({ "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);
|
||||
});
|
||||
|
||||
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="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">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="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+88
-202
@@ -3,18 +3,14 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { BinaryFiles } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import {
|
||||
ALLOWED_PASTE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "./constants";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { isMemberOf, isPromiseLike } from "./utils";
|
||||
import { t } from "./i18n";
|
||||
import { isPromiseLike, isTestEnv } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
@@ -34,11 +30,8 @@ export interface ClipboardData {
|
||||
programmaticAPI?: boolean;
|
||||
}
|
||||
|
||||
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
||||
|
||||
type ParsedClipboardEvent =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "mixedContent"; value: PastedMixedContent };
|
||||
let CLIPBOARD = "";
|
||||
let PREFER_APP_CLIPBOARD = false;
|
||||
|
||||
export const probablySupportsClipboardReadText =
|
||||
"clipboard" in navigator && "readText" in navigator.clipboard;
|
||||
@@ -68,61 +61,10 @@ const clipboardContainsElements = (
|
||||
return false;
|
||||
};
|
||||
|
||||
export const createPasteEvent = ({
|
||||
types,
|
||||
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;
|
||||
}) => {
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
files: BinaryFiles | null,
|
||||
) => {
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => element.type === "frame"),
|
||||
);
|
||||
@@ -144,7 +86,7 @@ export const serializeAsClipboardJSON = ({
|
||||
);
|
||||
}
|
||||
|
||||
// select bound text elements when copying
|
||||
// select binded text elements when copying
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: elements.map((element) => {
|
||||
@@ -163,20 +105,34 @@ export const serializeAsClipboardJSON = ({
|
||||
}),
|
||||
files: files ? _files : undefined,
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
|
||||
return JSON.stringify(contents);
|
||||
if (isTestEnv()) {
|
||||
return json;
|
||||
}
|
||||
|
||||
CLIPBOARD = json;
|
||||
|
||||
try {
|
||||
PREFER_APP_CLIPBOARD = false;
|
||||
await copyTextToSystemClipboard(json);
|
||||
} catch (error: any) {
|
||||
PREFER_APP_CLIPBOARD = true;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
files: BinaryFiles | null,
|
||||
/** supply if available to make the operation more certain to succeed */
|
||||
clipboardEvent?: ClipboardEvent | null,
|
||||
) => {
|
||||
await copyTextToSystemClipboard(
|
||||
serializeAsClipboardJSON({ elements, files }),
|
||||
clipboardEvent,
|
||||
);
|
||||
const getAppClipboard = (): Partial<ElementsClipboard> => {
|
||||
if (!CLIPBOARD) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(CLIPBOARD);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const parsePotentialSpreadsheet = (
|
||||
@@ -210,9 +166,7 @@ function parseHTMLTree(el: ChildNode) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const maybeParseHTMLPaste = (
|
||||
event: ClipboardEvent,
|
||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||
const maybeParseHTMLPaste = (event: ClipboardEvent) => {
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
|
||||
if (!html) {
|
||||
@@ -225,7 +179,7 @@ const maybeParseHTMLPaste = (
|
||||
const content = parseHTMLTree(doc.body);
|
||||
|
||||
if (content.length) {
|
||||
return { type: "mixedContent", value: content };
|
||||
return content;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`error in parseHTMLFromPaste: ${error.message}`);
|
||||
@@ -234,85 +188,21 @@ const maybeParseHTMLPaste = (
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses "paste" ClipboardEvent.
|
||||
* Retrieves content from system clipboard (either from ClipboardEvent or
|
||||
* via async clipboard API if supported)
|
||||
*/
|
||||
const parseClipboardEvent = async (
|
||||
const getSystemClipboard = async (
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ParsedClipboardEvent> => {
|
||||
): Promise<
|
||||
| { type: "text"; value: string }
|
||||
| { type: "mixedContent"; value: PastedMixedContent }
|
||||
> => {
|
||||
try {
|
||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||
|
||||
if (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;
|
||||
return { type: "mixedContent", value: mixedContent };
|
||||
}
|
||||
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
@@ -330,29 +220,37 @@ export const parseClipboard = async (
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ClipboardData> => {
|
||||
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
|
||||
const systemClipboard = await getSystemClipboard(event, isPlainPaste);
|
||||
|
||||
if (parsedEventData.type === "mixedContent") {
|
||||
if (systemClipboard.type === "mixedContent") {
|
||||
return {
|
||||
mixedContent: parsedEventData.value,
|
||||
mixedContent: systemClipboard.value,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// if system clipboard contains spreadsheet, use it even though it's
|
||||
// technically possible it's staler than in-app clipboard
|
||||
const spreadsheetResult =
|
||||
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
|
||||
|
||||
if (spreadsheetResult) {
|
||||
return spreadsheetResult;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
||||
// elements
|
||||
if (
|
||||
!systemClipboard ||
|
||||
(!isPlainPaste && systemClipboard.value.includes(SVG_EXPORT_TAG))
|
||||
) {
|
||||
return getAppClipboard();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const systemClipboardData = JSON.parse(parsedEventData.value);
|
||||
const systemClipboardData = JSON.parse(systemClipboard.value);
|
||||
const programmaticAPI =
|
||||
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
@@ -365,9 +263,18 @@ export const parseClipboard = async (
|
||||
programmaticAPI,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return { text: parsedEventData.value };
|
||||
} catch (e) {}
|
||||
// system clipboard doesn't contain excalidraw elements → return plaintext
|
||||
// unless we set a flag to prefer in-app clipboard because browser didn't
|
||||
// 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>) => {
|
||||
@@ -400,49 +307,28 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (
|
||||
text: string | null,
|
||||
clipboardEvent?: ClipboardEvent | null,
|
||||
) => {
|
||||
// (1) first try using Async Clipboard API
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
let copied = false;
|
||||
if (probablySupportsClipboardWriteText) {
|
||||
try {
|
||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||
// not focused
|
||||
await navigator.clipboard.writeText(text || "");
|
||||
return;
|
||||
copied = true;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
|
||||
try {
|
||||
if (clipboardEvent) {
|
||||
clipboardEvent.clipboardData?.setData("text/plain", text || "");
|
||||
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
|
||||
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"));
|
||||
// Note that 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 (!copied && !copyTextViaExecCommand(text || " ")) {
|
||||
throw new Error("couldn't copy");
|
||||
}
|
||||
};
|
||||
|
||||
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
||||
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 copyTextViaExecCommand = (text: string) => {
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
|
||||
+104
-56
@@ -34,7 +34,6 @@ import {
|
||||
EmbedIcon,
|
||||
extraToolsIcon,
|
||||
frameToolIcon,
|
||||
mermaidLogoIcon,
|
||||
laserPointerToolIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
@@ -202,8 +201,8 @@ export const SelectedShapeActions = ({
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{!device.editor.isMobile && renderAction("duplicateSelection")}
|
||||
{!device.editor.isMobile && renderAction("deleteSelectedElements")}
|
||||
{!device.isMobile && renderAction("duplicateSelection")}
|
||||
{!device.isMobile && renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
@@ -224,6 +223,7 @@ export const ShapesSwitcher = ({
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
const device = useDevice();
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
@@ -273,63 +273,111 @@ export const ShapesSwitcher = ({
|
||||
);
|
||||
})}
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<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}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||
className="App-toolbar__extra-tools-dropdown"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "frame" })}
|
||||
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
|
||||
{device.isMobile ? (
|
||||
<>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "embeddable" })}
|
||||
checked={activeTool.type === "frame"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(
|
||||
t("toolBar.frame"),
|
||||
)} — ${KEYS.F.toLocaleUpperCase()}`}
|
||||
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
|
||||
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
|
||||
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}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
checked={activeTool.type === "embeddable"}
|
||||
name="editor-current-shape"
|
||||
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")}
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "laser" })}
|
||||
icon={laserPointerToolIcon}
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
{extraToolsIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||
className="App-toolbar__extra-tools-dropdown"
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setOpenDialog("mermaid")}
|
||||
icon={mermaidLogoIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
{t("toolBar.mermaidToExcalidraw")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
app.setActiveTool({ type: "frame" });
|
||||
}}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+140
-170
@@ -74,6 +74,7 @@ import {
|
||||
MQ_MAX_WIDTH_LANDSCAPE,
|
||||
MQ_MAX_WIDTH_PORTRAIT,
|
||||
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
|
||||
MQ_SM_MAX_WIDTH,
|
||||
POINTER_BUTTON,
|
||||
ROUNDNESS,
|
||||
SCROLL_TIMEOUT,
|
||||
@@ -87,7 +88,7 @@ import {
|
||||
ZOOM_STEP,
|
||||
POINTER_EVENTS,
|
||||
} from "../constants";
|
||||
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
|
||||
import { exportCanvas, loadFromBlob } from "../data";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { restore, restoreElements } from "../data/restore";
|
||||
import {
|
||||
@@ -240,6 +241,7 @@ import {
|
||||
isInputLike,
|
||||
isToolIcon,
|
||||
isWritableElement,
|
||||
resolvablePromise,
|
||||
sceneCoordsToViewportCoords,
|
||||
tupleToCoors,
|
||||
viewportCoordsToSceneCoords,
|
||||
@@ -316,7 +318,7 @@ import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import {
|
||||
getFrameChildren,
|
||||
getFrameElements,
|
||||
isCursorInFrame,
|
||||
bindElementsToFramesAfterDuplication,
|
||||
addElementsToFrame,
|
||||
@@ -364,7 +366,6 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||
import { Renderer } from "../scene/Renderer";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import MermaidToExcalidraw from "./MermaidToExcalidraw";
|
||||
import { LaserToolOverlay } from "./LaserTool/LaserTool";
|
||||
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
||||
import {
|
||||
@@ -379,15 +380,11 @@ const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
viewport: {
|
||||
isMobile: false,
|
||||
isLandscape: false,
|
||||
},
|
||||
editor: {
|
||||
isMobile: false,
|
||||
canFitSidebar: false,
|
||||
},
|
||||
isSmScreen: false,
|
||||
isMobile: false,
|
||||
isTouchScreen: false,
|
||||
canDeviceFitSidebar: false,
|
||||
isLandscape: false,
|
||||
};
|
||||
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
||||
DeviceContext.displayName = "DeviceContext";
|
||||
@@ -438,9 +435,6 @@ export const useExcalidrawSetAppState = () =>
|
||||
export const useExcalidrawActionManager = () =>
|
||||
useContext(ExcalidrawActionManagerContext);
|
||||
|
||||
const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
let isHoldingSpace: boolean = false;
|
||||
@@ -477,6 +471,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
unmounted: boolean = false;
|
||||
actionManager: ActionManager;
|
||||
device: Device = deviceContextInitialValue;
|
||||
detachIsMobileMqHandler?: () => void;
|
||||
|
||||
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
@@ -539,7 +534,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const {
|
||||
excalidrawAPI,
|
||||
excalidrawRef,
|
||||
viewModeEnabled = false,
|
||||
zenModeEnabled = false,
|
||||
gridModeEnabled = false,
|
||||
@@ -570,8 +565,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.rc = rough.canvas(this.canvas);
|
||||
this.renderer = new Renderer(this.scene);
|
||||
|
||||
if (excalidrawAPI) {
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
|
||||
resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
ready: true,
|
||||
readyPromise,
|
||||
updateScene: this.updateScene,
|
||||
updateLibrary: this.library.updateLibrary,
|
||||
addFiles: this.addFiles,
|
||||
@@ -596,11 +597,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||
} as const;
|
||||
if (typeof excalidrawAPI === "function") {
|
||||
excalidrawAPI(api);
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
} else {
|
||||
console.error("excalidrawAPI should be a function!");
|
||||
excalidrawRef.current = api;
|
||||
}
|
||||
readyPromise.resolve(api);
|
||||
}
|
||||
|
||||
this.excalidrawContainerValue = {
|
||||
@@ -1040,6 +1042,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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 reset = () => {
|
||||
@@ -1084,12 +1092,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
boxShadow: "inset 0 0 0 1px var(--color-primary)",
|
||||
fontFamily: "Assistant",
|
||||
fontSize: "14px",
|
||||
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
|
||||
transform: `translateY(-${FRAME_NAME_EDIT_PADDING}px)`,
|
||||
color: "var(--color-gray-80)",
|
||||
overflow: "hidden",
|
||||
maxWidth: `${
|
||||
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
|
||||
}px`,
|
||||
maxWidth: `${Math.min(
|
||||
x2 - x1 - FRAME_NAME_EDIT_PADDING,
|
||||
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING,
|
||||
)}px`,
|
||||
}}
|
||||
size={frameNameInEdit.length + 1 || 1}
|
||||
dir="auto"
|
||||
@@ -1111,26 +1120,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
key={f.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
// Positioning from bottom so that we don't to either
|
||||
// calculate text height or adjust using transform (which)
|
||||
// messes up input position when editing the frame name.
|
||||
// This makes the positioning deterministic and we can calculate
|
||||
// the same position when rendering to canvas / svg.
|
||||
bottom: `${
|
||||
this.state.height +
|
||||
FRAME_STYLE.nameOffsetY -
|
||||
y1 +
|
||||
this.state.offsetTop
|
||||
top: `${y1 - FRAME_NAME_GAP - this.state.offsetTop}px`,
|
||||
left: `${
|
||||
x1 -
|
||||
this.state.offsetLeft -
|
||||
(this.state.editingFrame === f.id ? FRAME_NAME_EDIT_PADDING : 0)
|
||||
}px`,
|
||||
left: `${x1 - this.state.offsetLeft}px`,
|
||||
zIndex: 2,
|
||||
fontSize: FRAME_STYLE.nameFontSize,
|
||||
fontSize: "14px",
|
||||
color: isDarkTheme
|
||||
? FRAME_STYLE.nameColorDarkTheme
|
||||
: FRAME_STYLE.nameColorLightTheme,
|
||||
lineHeight: FRAME_STYLE.nameLineHeight,
|
||||
? "var(--color-gray-60)"
|
||||
: "var(--color-gray-50)",
|
||||
width: "max-content",
|
||||
maxWidth: `${f.width}px`,
|
||||
maxWidth: `${x2 - x1 + FRAME_NAME_EDIT_PADDING * 2}px`,
|
||||
overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -1173,29 +1175,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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 (
|
||||
<div
|
||||
className={clsx("excalidraw excalidraw-container", {
|
||||
"excalidraw--view-mode": this.state.viewModeEnabled,
|
||||
"excalidraw--mobile": this.device.editor.isMobile,
|
||||
"excalidraw--mobile": this.device.isMobile,
|
||||
})}
|
||||
style={{
|
||||
["--ui-pointerEvents" as any]: shouldBlockPointerEvents
|
||||
? POINTER_EVENTS.disabled
|
||||
: POINTER_EVENTS.enabled,
|
||||
["--ui-pointerEvents" as any]:
|
||||
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))
|
||||
? POINTER_EVENTS.disabled
|
||||
: POINTER_EVENTS.enabled,
|
||||
}}
|
||||
ref={this.excalidrawContainerRef}
|
||||
onDrop={this.handleAppOnDrop}
|
||||
@@ -1248,11 +1245,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
>
|
||||
{this.props.children}
|
||||
{this.state.openDialog === "mermaid" && (
|
||||
<MermaidToExcalidraw />
|
||||
)}
|
||||
</LayerUI>
|
||||
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
@@ -1282,10 +1275,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
top={this.state.contextMenu.top}
|
||||
left={this.state.contextMenu.left}
|
||||
actionManager={this.actionManager}
|
||||
onClose={(callback) => {
|
||||
onClose={(cb) => {
|
||||
this.setState({ contextMenu: null }, () => {
|
||||
this.focusContainer();
|
||||
callback?.();
|
||||
cb?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -1367,8 +1360,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
public onExportImage = async (
|
||||
type: keyof typeof EXPORT_IMAGE_TYPES,
|
||||
elements: ExportedElements,
|
||||
opts: { exportingFrame: ExcalidrawFrameElement | null },
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
trackEvent("export", type, "ui");
|
||||
const fileHandle = await exportCanvas(
|
||||
@@ -1380,7 +1372,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
exportBackground: this.state.exportBackground,
|
||||
name: this.state.name,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
exportingFrame: opts.exportingFrame,
|
||||
},
|
||||
)
|
||||
.catch(muteFSAbortError)
|
||||
@@ -1661,62 +1652,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
private isMobileBreakpoint = (width: number, height: number) => {
|
||||
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();
|
||||
|
||||
private refreshDeviceState = (container: HTMLDivElement) => {
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
const sidebarBreakpoint =
|
||||
this.props.UIOptions.dockedSidebarBreakpoint != null
|
||||
? this.props.UIOptions.dockedSidebarBreakpoint
|
||||
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
|
||||
|
||||
const prevEditorState = this.device.editor;
|
||||
|
||||
const nextEditorState = updateObject(prevEditorState, {
|
||||
isMobile: this.isMobileBreakpoint(editorWidth, editorHeight),
|
||||
canFitSidebar: editorWidth > sidebarBreakpoint,
|
||||
this.device = updateObject(this.device, {
|
||||
isLandscape: width > height,
|
||||
isSmScreen: width < MQ_SM_MAX_WIDTH,
|
||||
isMobile:
|
||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE),
|
||||
canDeviceFitSidebar: width > sidebarBreakpoint,
|
||||
});
|
||||
|
||||
if (prevEditorState !== nextEditorState) {
|
||||
this.device = { ...this.device, editor: nextEditorState };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
public async componentDidMount() {
|
||||
@@ -1758,21 +1707,52 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
this.excalidrawContainerRef.current &&
|
||||
// bounding rects don't work in tests so updating
|
||||
// the state on init would result in making the test enviro run
|
||||
// in mobile breakpoint (0 width/height), making everything fail
|
||||
!isTestEnv()
|
||||
) {
|
||||
this.refreshViewportBreakpoints();
|
||||
this.refreshEditorBreakpoints();
|
||||
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
||||
}
|
||||
|
||||
if (supportsResizeObserver && this.excalidrawContainerRef.current) {
|
||||
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.refreshEditorBreakpoints();
|
||||
// recompute device dimensions state
|
||||
// ---------------------------------------------------------------------
|
||||
this.refreshDeviceState(this.excalidrawContainerRef.current!);
|
||||
// refresh offsets
|
||||
// ---------------------------------------------------------------------
|
||||
this.updateDOMRect();
|
||||
});
|
||||
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));
|
||||
@@ -1817,11 +1797,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.forEach((element) => ShapeCache.delete(element));
|
||||
this.refreshViewportBreakpoints();
|
||||
this.updateDOMRect();
|
||||
if (!supportsResizeObserver) {
|
||||
this.refreshEditorBreakpoints();
|
||||
}
|
||||
this.setState({});
|
||||
});
|
||||
|
||||
@@ -1875,6 +1850,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
false,
|
||||
);
|
||||
|
||||
this.detachIsMobileMqHandler?.();
|
||||
window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
|
||||
}
|
||||
|
||||
@@ -1959,10 +1935,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
this.excalidrawContainerRef.current &&
|
||||
prevProps.UIOptions.dockedSidebarBreakpoint !==
|
||||
this.props.UIOptions.dockedSidebarBreakpoint
|
||||
this.props.UIOptions.dockedSidebarBreakpoint
|
||||
) {
|
||||
this.refreshEditorBreakpoints();
|
||||
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -2139,7 +2116,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!isExcalidrawActive || isWritableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.actionManager.executeAction(actionCut, "keyboard", event);
|
||||
this.cutAll();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
@@ -2151,11 +2128,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!isExcalidrawActive || isWritableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.actionManager.executeAction(actionCopy, "keyboard", event);
|
||||
this.copyAll();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
private cutAll = () => {
|
||||
this.actionManager.executeAction(actionCut, "keyboard");
|
||||
};
|
||||
|
||||
private copyAll = () => {
|
||||
this.actionManager.executeAction(actionCopy, "keyboard");
|
||||
};
|
||||
|
||||
private static resetTapTwice() {
|
||||
didTapTwice = false;
|
||||
}
|
||||
@@ -2217,13 +2202,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
public pasteFromClipboard = withBatchedUpdates(
|
||||
async (event: ClipboardEvent) => {
|
||||
const isPlainPaste = !!IS_PLAIN_PASTE;
|
||||
const isPlainPaste = !!(IS_PLAIN_PASTE && event);
|
||||
|
||||
console.warn(
|
||||
"pasteFromClipboard",
|
||||
event?.clipboardData?.types,
|
||||
event?.clipboardData?.getData("text/plain"),
|
||||
);
|
||||
|
||||
// #686
|
||||
const target = document.activeElement;
|
||||
const isExcalidrawActive =
|
||||
this.excalidrawContainerRef.current?.contains(target);
|
||||
if (event && !isExcalidrawActive) {
|
||||
console.log("exit (1)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2236,6 +2228,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
|
||||
isWritableElement(target))
|
||||
) {
|
||||
console.log("exit (2)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2347,12 +2340,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
|
||||
addElementsFromPasteOrLibrary = (opts: {
|
||||
private addElementsFromPasteOrLibrary = (opts: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||
retainSeed?: boolean;
|
||||
fitToContent?: boolean;
|
||||
}) => {
|
||||
const elements = restoreElements(opts.elements, null, undefined);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
@@ -2428,7 +2420,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// from library, not when pasting from clipboard. Alas.
|
||||
openSidebar:
|
||||
this.state.openSidebar &&
|
||||
this.device.editor.canFitSidebar &&
|
||||
this.device.canDeviceFitSidebar &&
|
||||
jotaiStore.get(isSidebarDockedAtom)
|
||||
? this.state.openSidebar
|
||||
: null,
|
||||
@@ -2457,12 +2449,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
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
|
||||
@@ -2642,7 +2628,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isPlainPaste &&
|
||||
textElements.length > 1 &&
|
||||
PLAIN_PASTE_TOAST_SHOWN === false &&
|
||||
!this.device.editor.isMobile
|
||||
!this.device.isMobile
|
||||
) {
|
||||
this.setToast({
|
||||
message: t("toast.pasteAsSingleElement", {
|
||||
@@ -2676,7 +2662,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
trackEvent(
|
||||
"toolbar",
|
||||
"toggleLock",
|
||||
`${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
`${source} (${this.device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
this.setState((prevState) => {
|
||||
@@ -2716,7 +2702,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
togglePenMode = (force: boolean | null) => {
|
||||
togglePenMode = (force?: boolean) => {
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
penMode: force ?? !prevState.penMode,
|
||||
@@ -3171,9 +3157,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
trackEvent(
|
||||
"toolbar",
|
||||
shape,
|
||||
`keyboard (${
|
||||
this.device.editor.isMobile ? "mobile" : "desktop"
|
||||
})`,
|
||||
`keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
this.setActiveTool({ type: shape });
|
||||
@@ -3340,10 +3324,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
setOpenDialog = (dialogType: AppState["openDialog"]) => {
|
||||
this.setState({ openDialog: dialogType });
|
||||
};
|
||||
|
||||
private setCursor = (cursor: string) => {
|
||||
setCursor(this.interactiveCanvas, cursor);
|
||||
};
|
||||
@@ -3907,7 +3887,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
element,
|
||||
this.state,
|
||||
[scenePointer.x, scenePointer.y],
|
||||
this.device.editor.isMobile,
|
||||
this.device.isMobile,
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -3939,7 +3919,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
this.state,
|
||||
[lastPointerDownCoords.x, lastPointerDownCoords.y],
|
||||
this.device.editor.isMobile,
|
||||
this.device.isMobile,
|
||||
);
|
||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||
this.lastPointerUpEvent!,
|
||||
@@ -3949,7 +3929,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
this.state,
|
||||
[lastPointerUpCoords.x, lastPointerUpCoords.y],
|
||||
this.device.editor.isMobile,
|
||||
this.device.isMobile,
|
||||
);
|
||||
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
||||
let url = this.hitLinkElement.link;
|
||||
@@ -4294,7 +4274,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
|
||||
this.hitLinkElement = this.getElementLinkAtPosition(
|
||||
scenePointer,
|
||||
hitElement,
|
||||
@@ -4740,13 +4719,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
|
||||
const { x, y } = viewportCoordsToSceneCoords(event, this.state);
|
||||
|
||||
const frame = this.getTopLayerFrameAtSceneCoords({ x, y });
|
||||
|
||||
mutateElement(pendingImageElement, {
|
||||
x,
|
||||
y,
|
||||
frameId: frame ? frame.id : null,
|
||||
});
|
||||
} else if (this.state.activeTool.type === "freedraw") {
|
||||
this.handleFreeDrawElementOnPointerDown(
|
||||
@@ -4815,7 +4790,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
const clicklength =
|
||||
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
|
||||
if (this.device.editor.isMobile && clicklength < 300) {
|
||||
if (this.device.isMobile && clicklength < 300) {
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
@@ -5333,7 +5308,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// if hitElement is frame, deselect all of its elements if they are selected
|
||||
if (hitElement.type === "frame") {
|
||||
getFrameChildren(
|
||||
getFrameElements(
|
||||
previouslySelectedElements,
|
||||
hitElement.id,
|
||||
).forEach((element) => {
|
||||
@@ -5613,11 +5588,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private createImageElement = ({
|
||||
sceneX,
|
||||
sceneY,
|
||||
addToFrameUnderCursor = true,
|
||||
}: {
|
||||
sceneX: number;
|
||||
sceneY: number;
|
||||
addToFrameUnderCursor?: boolean;
|
||||
}) => {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
sceneX,
|
||||
@@ -5627,12 +5600,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: this.state.gridSize,
|
||||
);
|
||||
|
||||
const topLayerFrame = addToFrameUnderCursor
|
||||
? this.getTopLayerFrameAtSceneCoords({
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
})
|
||||
: null;
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
});
|
||||
|
||||
const element = newImageElement({
|
||||
type: "image",
|
||||
@@ -7562,7 +7533,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const imageElement = this.createImageElement({
|
||||
sceneX: x,
|
||||
sceneY: y,
|
||||
addToFrameUnderCursor: false,
|
||||
});
|
||||
|
||||
if (insertOnCanvasDirectly) {
|
||||
@@ -8202,7 +8172,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
>();
|
||||
|
||||
selectedFrames.forEach((frame) => {
|
||||
const elementsInFrame = getFrameChildren(
|
||||
const elementsInFrame = getFrameElements(
|
||||
this.scene.getNonDeletedElements(),
|
||||
frame.id,
|
||||
);
|
||||
@@ -8272,7 +8242,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const elementsToHighlight = new Set<ExcalidrawElement>();
|
||||
selectedFrames.forEach((frame) => {
|
||||
const elementsInFrame = getFrameChildren(
|
||||
const elementsInFrame = getFrameElements(
|
||||
this.scene.getNonDeletedElements(),
|
||||
frame.id,
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ export const ColorInput = ({
|
||||
}}
|
||||
/>
|
||||
{/* TODO reenable on mobile with a better UX */}
|
||||
{!device.editor.isMobile && (
|
||||
{!device.isMobile && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -80,7 +80,7 @@ const ColorPickerPopupContent = ({
|
||||
);
|
||||
|
||||
const { container } = useExcalidrawContainer();
|
||||
const device = useDevice();
|
||||
const { isMobile, isLandscape } = useDevice();
|
||||
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
@@ -136,16 +136,8 @@ const ColorPickerPopupContent = ({
|
||||
updateData({ openPopup: null });
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "bottom"
|
||||
: "right"
|
||||
}
|
||||
align={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
side={isMobile && !isLandscape ? "bottom" : "right"}
|
||||
align={isMobile && !isLandscape ? "center" : "start"}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
|
||||
@@ -21,7 +21,7 @@ type ContextMenuProps = {
|
||||
items: ContextMenuItems;
|
||||
top: number;
|
||||
left: number;
|
||||
onClose: (callback?: () => void) => void;
|
||||
onClose: (cb?: () => void) => void;
|
||||
};
|
||||
|
||||
export const CONTEXT_MENU_SEPARATOR = "separator";
|
||||
@@ -50,9 +50,7 @@ export const ContextMenu = React.memo(
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={() => {
|
||||
onClose();
|
||||
}}
|
||||
onCloseRequest={() => onClose()}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
|
||||
@@ -33,16 +33,14 @@
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.Dialog--fullscreen {
|
||||
.Dialog__close {
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
const { id } = useExcalidrawContainer();
|
||||
const isFullscreen = useDevice().viewport.isMobile;
|
||||
const device = useDevice();
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
@@ -101,9 +101,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={clsx("Dialog", props.className, {
|
||||
"Dialog--fullscreen": isFullscreen,
|
||||
})}
|
||||
className={clsx("Dialog", props.className)}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={getDialogSize(props.size)}
|
||||
onCloseRequest={onClose}
|
||||
@@ -121,7 +119,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{isFullscreen ? back : CloseIcon}
|
||||
{device.isMobile ? back : CloseIcon}
|
||||
</button>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
</Island>
|
||||
|
||||
@@ -254,6 +254,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("helpDialog.movePageLeftRight")}
|
||||
shortcuts={["Shift+PgUp/PgDn"]}
|
||||
/>
|
||||
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
|
||||
<Shortcut
|
||||
label={t("buttons.zenMode")}
|
||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||
|
||||
@@ -22,7 +22,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||
if (appState.openSidebar && !device.canDeviceFitSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { canvasToBlob } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../packages/utils";
|
||||
|
||||
import { copyIcon, downloadIcon, helpIcon } from "./icons";
|
||||
@@ -34,8 +34,6 @@ import { Tooltip } from "./Tooltip";
|
||||
import "./ImageExportDialog.scss";
|
||||
import { useAppProps } from "./App";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { cloneJSON } from "../utils";
|
||||
import { prepareElementsForExport } from "../data";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@@ -53,47 +51,44 @@ export const ErrorCanvasPreview = () => {
|
||||
};
|
||||
|
||||
type ImageExportModalProps = {
|
||||
appStateSnapshot: Readonly<UIAppState>;
|
||||
elementsSnapshot: readonly NonDeletedExcalidrawElement[];
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
appStateSnapshot,
|
||||
elementsSnapshot,
|
||||
appState,
|
||||
elements,
|
||||
files,
|
||||
actionManager,
|
||||
onExportImage,
|
||||
}: ImageExportModalProps) => {
|
||||
const hasSelection = isSomeElementSelected(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
);
|
||||
|
||||
const appProps = useAppProps();
|
||||
const [projectName, setProjectName] = useState(appStateSnapshot.name);
|
||||
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
|
||||
const [projectName, setProjectName] = useState(appState.name);
|
||||
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
const [exportWithBackground, setExportWithBackground] = useState(
|
||||
appStateSnapshot.exportBackground,
|
||||
appState.exportBackground,
|
||||
);
|
||||
const [exportDarkMode, setExportDarkMode] = useState(
|
||||
appStateSnapshot.exportWithDarkMode,
|
||||
appState.exportWithDarkMode,
|
||||
);
|
||||
const [embedScene, setEmbedScene] = useState(
|
||||
appStateSnapshot.exportEmbedScene,
|
||||
);
|
||||
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
|
||||
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
|
||||
const [exportScale, setExportScale] = useState(appState.exportScale);
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
exportSelectionOnly,
|
||||
);
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
})
|
||||
: elements;
|
||||
|
||||
useEffect(() => {
|
||||
const previewNode = previewRef.current;
|
||||
@@ -107,18 +102,10 @@ const ImageExportModal = ({
|
||||
}
|
||||
exportToCanvas({
|
||||
elements: exportedElements,
|
||||
appState: {
|
||||
...appStateSnapshot,
|
||||
name: projectName,
|
||||
exportBackground: exportWithBackground,
|
||||
exportWithDarkMode: exportDarkMode,
|
||||
exportScale,
|
||||
exportEmbedScene: embedScene,
|
||||
},
|
||||
appState,
|
||||
files,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
|
||||
exportingFrame,
|
||||
})
|
||||
.then((canvas) => {
|
||||
setRenderError(null);
|
||||
@@ -132,17 +119,7 @@ const ImageExportModal = ({
|
||||
console.error(error);
|
||||
setRenderError(error);
|
||||
});
|
||||
}, [
|
||||
appStateSnapshot,
|
||||
files,
|
||||
exportedElements,
|
||||
exportingFrame,
|
||||
projectName,
|
||||
exportWithBackground,
|
||||
exportDarkMode,
|
||||
exportScale,
|
||||
embedScene,
|
||||
]);
|
||||
}, [appState, files, exportedElements]);
|
||||
|
||||
return (
|
||||
<div className="ImageExportModal">
|
||||
@@ -159,8 +136,7 @@ const ImageExportModal = ({
|
||||
value={projectName}
|
||||
style={{ width: "30ch" }}
|
||||
disabled={
|
||||
typeof appProps.name !== "undefined" ||
|
||||
appStateSnapshot.viewModeEnabled
|
||||
typeof appProps.name !== "undefined" || appState.viewModeEnabled
|
||||
}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
@@ -176,16 +152,16 @@ const ImageExportModal = ({
|
||||
</div>
|
||||
<div className="ImageExportModal__settings">
|
||||
<h3>{t("imageExportDialog.header")}</h3>
|
||||
{hasSelection && (
|
||||
{someElementIsSelected && (
|
||||
<ExportSetting
|
||||
label={t("imageExportDialog.label.onlySelected")}
|
||||
name="exportOnlySelected"
|
||||
>
|
||||
<Switch
|
||||
name="exportOnlySelected"
|
||||
checked={exportSelectionOnly}
|
||||
checked={exportSelected}
|
||||
onChange={(checked) => {
|
||||
setExportSelectionOnly(checked);
|
||||
setExportSelected(checked);
|
||||
}}
|
||||
/>
|
||||
</ExportSetting>
|
||||
@@ -267,9 +243,7 @@ const ImageExportModal = ({
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.exportToPng")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
|
||||
}
|
||||
startIcon={downloadIcon}
|
||||
>
|
||||
@@ -279,9 +253,7 @@ const ImageExportModal = ({
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.exportToSvg")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
|
||||
}
|
||||
startIcon={downloadIcon}
|
||||
>
|
||||
@@ -292,9 +264,7 @@ const ImageExportModal = ({
|
||||
className="ImageExportModal__settings__buttons__button"
|
||||
label={t("imageExportDialog.title.copyPngToClipboard")}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements, {
|
||||
exportingFrame,
|
||||
})
|
||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
|
||||
}
|
||||
startIcon={copyIcon}
|
||||
>
|
||||
@@ -355,20 +325,15 @@ export const ImageExportDialog = ({
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
// we need to take a snapshot so that the exported state can't be modified
|
||||
// while the dialog is open
|
||||
const [{ appStateSnapshot, elementsSnapshot }] = useState(() => {
|
||||
return {
|
||||
appStateSnapshot: cloneJSON(appState),
|
||||
elementsSnapshot: cloneJSON(elements),
|
||||
};
|
||||
});
|
||||
if (appState.openDialog !== "imageExport") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
|
||||
<ImageExportModal
|
||||
elementsSnapshot={elementsSnapshot}
|
||||
appStateSnapshot={appStateSnapshot}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
|
||||
+11
-14
@@ -66,7 +66,7 @@ interface LayerUIProps {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||
onPenModeToggle: () => void;
|
||||
showExitZenModeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
@@ -161,10 +161,7 @@ const LayerUI = ({
|
||||
};
|
||||
|
||||
const renderImageExportDialog = () => {
|
||||
if (
|
||||
!UIOptions.canvasActions.saveAsImage ||
|
||||
appState.openDialog !== "imageExport"
|
||||
) {
|
||||
if (!UIOptions.canvasActions.saveAsImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -249,7 +246,7 @@ const LayerUI = ({
|
||||
>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
isMobile={device.editor.isMobile}
|
||||
isMobile={device.isMobile}
|
||||
device={device}
|
||||
app={app}
|
||||
/>
|
||||
@@ -258,7 +255,7 @@ const LayerUI = ({
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
onChange={onPenModeToggle}
|
||||
title={t("toolBar.penMode")}
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
@@ -317,7 +314,7 @@ const LayerUI = ({
|
||||
)}
|
||||
>
|
||||
<UserList collaborators={appState.collaborators} />
|
||||
{renderTopRightUI?.(device.editor.isMobile, appState)}
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
{!appState.viewModeEnabled &&
|
||||
// hide button when sidebar docked
|
||||
(!isSidebarDocked ||
|
||||
@@ -338,7 +335,7 @@ const LayerUI = ({
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`toggleDock (${docked ? "dock" : "undock"})`,
|
||||
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
`(${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -366,7 +363,7 @@ const LayerUI = ({
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`${DEFAULT_SIDEBAR.name} (open)`,
|
||||
`button (${device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
`button (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -383,7 +380,7 @@ const LayerUI = ({
|
||||
{appState.errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
{eyeDropperState && !device.editor.isMobile && (
|
||||
{eyeDropperState && !device.isMobile && (
|
||||
<EyeDropper
|
||||
colorPickerType={eyeDropperState.colorPickerType}
|
||||
onCancel={() => {
|
||||
@@ -453,7 +450,7 @@ const LayerUI = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{device.editor.isMobile && (
|
||||
{device.isMobile && (
|
||||
<MobileMenu
|
||||
app={app}
|
||||
appState={appState}
|
||||
@@ -472,14 +469,14 @@ const LayerUI = ({
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
)}
|
||||
{!device.editor.isMobile && (
|
||||
{!device.isMobile && (
|
||||
<>
|
||||
<div
|
||||
className="layer-ui__wrapper"
|
||||
style={
|
||||
appState.openSidebar &&
|
||||
isSidebarDocked &&
|
||||
device.editor.canFitSidebar
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const LibraryUnit = memo(
|
||||
}, [svg]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useDevice().editor.isMobile;
|
||||
const isMobile = useDevice().isMobile;
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PlusIcon}</div>
|
||||
);
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
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;
|
||||
@@ -35,7 +35,7 @@ type MobileMenuProps = {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||
onPenModeToggle: () => void;
|
||||
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
@@ -94,7 +94,7 @@ export const MobileMenu = ({
|
||||
)}
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
onChange={onPenModeToggle}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
|
||||
@@ -59,6 +59,12 @@
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes Modal__background__fade-in {
|
||||
@@ -99,7 +105,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.Dialog--fullscreen {
|
||||
@include isMobile {
|
||||
.Modal {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -110,9 +116,6 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,11 +113,11 @@ export const SidebarInner = forwardRef(
|
||||
if ((event.target as Element).closest(".sidebar-trigger")) {
|
||||
return;
|
||||
}
|
||||
if (!docked || !device.editor.canFitSidebar) {
|
||||
if (!docked || !device.canDeviceFitSidebar) {
|
||||
closeLibrary();
|
||||
}
|
||||
},
|
||||
[closeLibrary, docked, device.editor.canFitSidebar],
|
||||
[closeLibrary, docked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -125,7 +125,7 @@ export const SidebarInner = forwardRef(
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!docked || !device.editor.canFitSidebar)
|
||||
(!docked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
closeLibrary();
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export const SidebarInner = forwardRef(
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [closeLibrary, docked, device.editor.canFitSidebar]);
|
||||
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
|
||||
|
||||
return (
|
||||
<Island
|
||||
|
||||
@@ -18,7 +18,7 @@ export const SidebarHeader = ({
|
||||
const props = useContext(SidebarPropsContext);
|
||||
|
||||
const renderDockButton = !!(
|
||||
device.editor.canFitSidebar && props.shouldRenderDockButton
|
||||
device.canDeviceFitSidebar && props.shouldRenderDockButton
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -160,15 +160,6 @@
|
||||
width: 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 {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
|
||||
@@ -16,10 +16,6 @@
|
||||
align-self: center;
|
||||
background-color: var(--default-border-color);
|
||||
margin: 0 0.25rem;
|
||||
|
||||
@include isMobile {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +41,5 @@
|
||||
margin-top: 0.375rem;
|
||||
right: 0;
|
||||
min-width: 11.875rem;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
row-gap: 0.75rem;
|
||||
|
||||
@@ -30,7 +30,7 @@ const MenuContent = ({
|
||||
});
|
||||
|
||||
const classNames = clsx(`dropdown-menu ${className}`, {
|
||||
"dropdown-menu--mobile": device.editor.isMobile,
|
||||
"dropdown-menu--mobile": device.isMobile,
|
||||
}).trim();
|
||||
|
||||
return (
|
||||
@@ -43,7 +43,7 @@ const MenuContent = ({
|
||||
>
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
{device.editor.isMobile ? (
|
||||
{device.isMobile ? (
|
||||
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||
) : (
|
||||
<Island
|
||||
|
||||
@@ -14,7 +14,7 @@ const MenuItemContent = ({
|
||||
<>
|
||||
<div className="dropdown-menu-item__icon">{icon}</div>
|
||||
<div className="dropdown-menu-item__text">{children}</div>
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
{shortcut && !device.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -18,7 +18,7 @@ const MenuTrigger = ({
|
||||
`dropdown-menu-button ${className}`,
|
||||
"zen-mode-transition",
|
||||
{
|
||||
"dropdown-menu-button--mobile": device.editor.isMobile,
|
||||
"dropdown-menu-button--mobile": device.isMobile,
|
||||
},
|
||||
).trim();
|
||||
return (
|
||||
|
||||
@@ -1654,22 +1654,6 @@ export const frameToolIcon = createIcon(
|
||||
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(
|
||||
<g
|
||||
fill="none"
|
||||
|
||||
@@ -29,7 +29,7 @@ const MainMenu = Object.assign(
|
||||
const device = useDevice();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const onClickOutside = device.editor.isMobile
|
||||
const onClickOutside = device.isMobile
|
||||
? undefined
|
||||
: () => setAppState({ openMenu: null });
|
||||
|
||||
@@ -54,7 +54,7 @@ const MainMenu = Object.assign(
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
{device.editor.isMobile && appState.collaborators.size > 0 && (
|
||||
{device.isMobile && appState.collaborators.size > 0 && (
|
||||
<fieldset className="UserList-Wrapper">
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList
|
||||
|
||||
@@ -21,7 +21,7 @@ const WelcomeScreenMenuItemContent = ({
|
||||
<>
|
||||
<div className="welcome-screen-menu-item__icon">{icon}</div>
|
||||
<div className="welcome-screen-menu-item__text">{children}</div>
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
{shortcut && !device.isMobile && (
|
||||
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
+3
-9
@@ -105,7 +105,6 @@ export const FONT_FAMILY = {
|
||||
Virgil: 1,
|
||||
Helvetica: 2,
|
||||
Cascadia: 3,
|
||||
Assistant: 4,
|
||||
};
|
||||
|
||||
export const THEME = {
|
||||
@@ -115,18 +114,13 @@ export const THEME = {
|
||||
|
||||
export const FRAME_STYLE = {
|
||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
|
||||
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
|
||||
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
|
||||
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
|
||||
roughness: 0 as ExcalidrawElement["roughness"],
|
||||
roundness: null as ExcalidrawElement["roundness"],
|
||||
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
|
||||
radius: 8,
|
||||
nameOffsetY: 3,
|
||||
nameColorLightTheme: "#999999",
|
||||
nameColorDarkTheme: "#7a7a7a",
|
||||
nameFontSize: 14,
|
||||
nameLineHeight: 1.25,
|
||||
};
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
@@ -154,8 +148,6 @@ export const IMAGE_MIME_TYPES = {
|
||||
jfif: "image/jfif",
|
||||
} as const;
|
||||
|
||||
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
@@ -226,6 +218,8 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
|
||||
// breakpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
// sm screen
|
||||
export const MQ_SM_MAX_WIDTH = 640;
|
||||
// md screen
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
|
||||
+5
-12
@@ -195,7 +195,7 @@
|
||||
.buttonList label:focus-within,
|
||||
input:focus-visible {
|
||||
outline: transparent;
|
||||
box-shadow: 0 0 0 1px var(--color-brand-hover);
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
@@ -280,11 +280,6 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
|
||||
.dropdown-menu--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.App-mobile-menu {
|
||||
@@ -542,13 +537,13 @@
|
||||
|
||||
&:not(:focus) {
|
||||
&:hover {
|
||||
border-color: var(--color-brand-hover);
|
||||
background-color: var(--input-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-hover);
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,8 +592,6 @@
|
||||
background-color: var(--island-bg-color);
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -608,8 +601,8 @@
|
||||
}
|
||||
|
||||
.App-toolbar--mobile {
|
||||
overflow: visible;
|
||||
max-width: 98vw;
|
||||
overflow-x: auto;
|
||||
max-width: 90vw;
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
display: none;
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ export const setCursorForShape = (
|
||||
interactiveCanvas.style.cursor = `url(${url}), auto`;
|
||||
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||
} else if (appState.activeTool.type !== "image") {
|
||||
} else {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.AUTO;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "#d8f5a2",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id45",
|
||||
"id": "id41",
|
||||
"type": "arrow",
|
||||
},
|
||||
{
|
||||
"id": "id46",
|
||||
"id": "id42",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -45,7 +45,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id46",
|
||||
"id": "id42",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -110,7 +110,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id47",
|
||||
"elementId": "id43",
|
||||
"focus": -0.08139534883720931,
|
||||
"gap": 1,
|
||||
},
|
||||
@@ -186,7 +186,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id45",
|
||||
"id": "id41",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -222,7 +222,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"baseline": 0,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id48",
|
||||
"id": "id44",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -266,7 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"baseline": 0,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id48",
|
||||
"id": "id44",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -309,7 +309,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id49",
|
||||
"id": "id45",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@@ -367,7 +367,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"backgroundColor": "transparent",
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id48",
|
||||
"containerId": "id44",
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -401,6 +401,173 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id34",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id36",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
"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": {
|
||||
"elementId": "id35",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 255,
|
||||
"y": 239,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id33",
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
"id": Any<String>,
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"originalText": "HELLO WORLD!!",
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"text": "HELLO WORLD!!",
|
||||
"textAlign": "center",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 130,
|
||||
"x": 240,
|
||||
"y": 226.5,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id33",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": Any<String>,
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 155,
|
||||
"y": 189,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id33",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": Any<String>,
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 355,
|
||||
"y": 189,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
@@ -458,180 +625,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id37",
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 25,
|
||||
"id": Any<String>,
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"originalText": "HELLO WORLD!!",
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"text": "HELLO WORLD!!",
|
||||
"textAlign": "center",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 130,
|
||||
"x": 240,
|
||||
"y": 226.5,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id37",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": Any<String>,
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 155,
|
||||
"y": 189,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id37",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": Any<String>,
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 355,
|
||||
"y": 189,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id42",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id44",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
"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": {
|
||||
"elementId": "id43",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 255,
|
||||
"y": 239,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"baseline": 0,
|
||||
"boundElements": null,
|
||||
"containerId": "id41",
|
||||
"containerId": "id37",
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
@@ -671,7 +671,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"baseline": 0,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id41",
|
||||
"id": "id37",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -715,7 +715,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"baseline": 0,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id41",
|
||||
"id": "id37",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -1335,7 +1335,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
"height": 130,
|
||||
"id": Any<String>,
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
@@ -1362,7 +1362,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 100,
|
||||
@@ -1385,7 +1385,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 0,
|
||||
"height": 130,
|
||||
"id": Any<String>,
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
@@ -1412,7 +1412,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 100,
|
||||
|
||||
+2
-66
@@ -3,19 +3,11 @@ import {
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
|
||||
import { getNonDeletedElements, isFrameElement } from "../element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { elementsOverlappingBBox } from "../packages/withinBounds";
|
||||
import { isSomeElementSelected, getSelectedElements } from "../scene";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { cloneJSON } from "../utils";
|
||||
import { canvasToBlob } from "./blob";
|
||||
import { fileSave, FileSystemHandle } from "./filesystem";
|
||||
import { serializeAsJSON } from "./json";
|
||||
@@ -23,61 +15,9 @@ import { serializeAsJSON } from "./json";
|
||||
export { loadFromBlob } from "./blob";
|
||||
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 (
|
||||
type: Omit<ExportType, "backend">,
|
||||
elements: ExportedElements,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
{
|
||||
@@ -86,14 +26,12 @@ export const exportCanvas = async (
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
fileHandle = null,
|
||||
exportingFrame = null,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
name: string;
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
exportingFrame: ExcalidrawFrameElement | null;
|
||||
},
|
||||
) => {
|
||||
if (elements.length === 0) {
|
||||
@@ -111,7 +49,6 @@ export const exportCanvas = async (
|
||||
exportEmbedScene: appState.exportEmbedScene && type === "svg",
|
||||
},
|
||||
files,
|
||||
{ exportingFrame },
|
||||
);
|
||||
if (type === "svg") {
|
||||
return await fileSave(
|
||||
@@ -133,7 +70,6 @@ export const exportCanvas = async (
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
exportingFrame,
|
||||
});
|
||||
|
||||
if (type === "png") {
|
||||
|
||||
+1
-2
@@ -23,7 +23,6 @@ import {
|
||||
LIBRARY_SIDEBAR_TAB,
|
||||
} from "../constants";
|
||||
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
||||
import { cloneJSON } from "../utils";
|
||||
|
||||
export const libraryItemsAtom = atom<{
|
||||
status: "loading" | "loaded";
|
||||
@@ -32,7 +31,7 @@ export const libraryItemsAtom = atom<{
|
||||
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
||||
|
||||
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||
cloneJSON(libraryItems);
|
||||
JSON.parse(JSON.stringify(libraryItems));
|
||||
|
||||
/**
|
||||
* checks if library item does not exist already in current library
|
||||
|
||||
+12
-12
@@ -1,6 +1,7 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { exportCanvas, prepareElementsForExport } from ".";
|
||||
import { exportCanvas } from ".";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { getFileHandleType, isImageFileHandleType } from "./blob";
|
||||
|
||||
export const resaveAsImageWithScene = async (
|
||||
@@ -22,19 +23,18 @@ export const resaveAsImageWithScene = async (
|
||||
exportEmbedScene: true,
|
||||
};
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elements,
|
||||
await exportCanvas(
|
||||
fileHandleType,
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
false,
|
||||
files,
|
||||
{
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
fileHandle,
|
||||
},
|
||||
);
|
||||
|
||||
await exportCanvas(fileHandleType, exportedElements, appState, files, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
name,
|
||||
fileHandle,
|
||||
exportingFrame,
|
||||
});
|
||||
|
||||
return { fileHandle };
|
||||
};
|
||||
|
||||
@@ -309,90 +309,6 @@ 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", () => {
|
||||
it("should bind arrows to shapes when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
|
||||
+11
-76
@@ -5,7 +5,6 @@ import {
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
getCommonBounds,
|
||||
newElement,
|
||||
newLinearElement,
|
||||
redrawTextBoundingBox,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
import { bindLinearElement } from "../element/binding";
|
||||
import {
|
||||
ElementConstructorOpts,
|
||||
newFrameElement,
|
||||
newImageElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
@@ -40,7 +38,7 @@ import {
|
||||
VerticalAlign,
|
||||
} from "../element/types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import { assertNever, cloneJSON, getFontString } from "../utils";
|
||||
import { assertNever, getFontString } from "../utils";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomId } from "../random";
|
||||
|
||||
@@ -137,7 +135,9 @@ export type ValidContainer =
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawFrameElement
|
||||
>
|
||||
| ({
|
||||
type: Extract<ExcalidrawLinearElement["type"], "line">;
|
||||
@@ -158,12 +158,7 @@ export type ExcalidrawElementSkeleton =
|
||||
x: number;
|
||||
y: number;
|
||||
fileId: FileId;
|
||||
} & Partial<ExcalidrawImageElement>)
|
||||
| ({
|
||||
type: "frame";
|
||||
children: readonly ExcalidrawElement["id"][];
|
||||
name?: string;
|
||||
} & Partial<ExcalidrawFrameElement>);
|
||||
} & Partial<ExcalidrawImageElement>);
|
||||
|
||||
const DEFAULT_LINEAR_ELEMENT_PROPS = {
|
||||
width: 100,
|
||||
@@ -368,8 +363,7 @@ 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][];
|
||||
const newPoints = JSON.parse(JSON.stringify(linearElement.points));
|
||||
// left to right so shift the arrow towards right
|
||||
if (
|
||||
linearElement.points[endPointIndex][0] >
|
||||
@@ -440,7 +434,10 @@ export const convertToExcalidrawElements = (
|
||||
if (!elementsSkeleton) {
|
||||
return [];
|
||||
}
|
||||
const elements = cloneJSON(elementsSkeleton);
|
||||
const elements: ExcalidrawElementSkeleton[] = JSON.parse(
|
||||
JSON.stringify(elementsSkeleton),
|
||||
);
|
||||
|
||||
const elementStore = new ElementStore();
|
||||
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
||||
const oldToNewElementIdMap = new Map<string, string>();
|
||||
@@ -539,15 +536,8 @@ export const convertToExcalidrawElements = (
|
||||
|
||||
break;
|
||||
}
|
||||
case "frame": {
|
||||
excalidrawElement = newFrameElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "frame":
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
break;
|
||||
@@ -651,60 +641,5 @@ 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();
|
||||
};
|
||||
|
||||
@@ -64,7 +64,6 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"dddice.com",
|
||||
]);
|
||||
|
||||
const createSrcDoc = (body: string) => {
|
||||
@@ -201,7 +200,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
return { link, aspectRatio, type };
|
||||
};
|
||||
|
||||
export const isEmbeddableOrLabel = (
|
||||
export const isEmbeddableOrFrameLabel = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
): Boolean => {
|
||||
if (isEmbeddableElement(element)) {
|
||||
|
||||
@@ -144,15 +144,13 @@ export const newEmbeddableElement = (
|
||||
};
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: {
|
||||
name?: string;
|
||||
} & ElementConstructorOpts,
|
||||
opts: ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
|
||||
type: "frame",
|
||||
name: opts?.name || null,
|
||||
name: null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ export const redrawTextBoundingBox = (
|
||||
);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
const nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
|
||||
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
||||
import { getTextEditor } from "../tests/queries/dom";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
@@ -26,7 +26,10 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
@@ -192,7 +195,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, false);
|
||||
const editor = await getTextEditor(false);
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
@@ -214,26 +217,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, false);
|
||||
const editor = await getTextEditor(false);
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
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", () => {
|
||||
@@ -249,15 +238,13 @@ describe("textWysiwyg", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
//@ts-ignore
|
||||
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
||||
|
||||
textElement = UI.createElement("text");
|
||||
|
||||
mouse.clickOn(textElement);
|
||||
textarea = await getTextEditor(textEditorSelector, true);
|
||||
textarea = await getTextEditor(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -467,7 +454,7 @@ describe("textWysiwyg", () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
textarea = await getTextEditor(textEditorSelector, true);
|
||||
textarea = await getTextEditor(true);
|
||||
updateTextEditor(
|
||||
textarea,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
@@ -519,7 +506,7 @@ describe("textWysiwyg", () => {
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@@ -547,7 +534,7 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@@ -574,7 +561,7 @@ describe("textWysiwyg", () => {
|
||||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
@@ -609,7 +596,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
let editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
@@ -624,7 +611,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
mouse.down();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -646,7 +633,7 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@@ -681,7 +668,7 @@ describe("textWysiwyg", () => {
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -706,7 +693,7 @@ describe("textWysiwyg", () => {
|
||||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
@@ -740,7 +727,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@@ -755,7 +742,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
updateTextEditor(
|
||||
editor,
|
||||
@@ -800,7 +787,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
let editor = await getTextEditor(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
@@ -813,7 +800,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
@@ -846,7 +833,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
let editor = await getTextEditor(true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@@ -867,7 +854,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
updateTextEditor(editor, "Hello");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -896,7 +883,7 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@@ -933,7 +920,7 @@ describe("textWysiwyg", () => {
|
||||
// Bind first text
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
@@ -954,7 +941,7 @@ describe("textWysiwyg", () => {
|
||||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
let editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
@@ -971,7 +958,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
|
||||
editor.select();
|
||||
|
||||
@@ -994,7 +981,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
|
||||
editor.select();
|
||||
|
||||
@@ -1032,7 +1019,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
@@ -1047,7 +1034,7 @@ describe("textWysiwyg", () => {
|
||||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
@@ -1067,7 +1054,7 @@ describe("textWysiwyg", () => {
|
||||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
@@ -1099,7 +1086,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
it("undo should work", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
@@ -1136,7 +1123,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
it("should not allow bound text with only whitespaces", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
updateTextEditor(editor, " ");
|
||||
@@ -1191,7 +1178,7 @@ describe("textWysiwyg", () => {
|
||||
it("should reset the container height cache when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
let editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
@@ -1203,7 +1190,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@@ -1219,7 +1206,7 @@ describe("textWysiwyg", () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
||||
@@ -1244,7 +1231,7 @@ describe("textWysiwyg", () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(
|
||||
@@ -1276,12 +1263,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
editor.select();
|
||||
});
|
||||
|
||||
@@ -1392,7 +1379,7 @@ describe("textWysiwyg", () => {
|
||||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(true);
|
||||
|
||||
updateTextEditor(
|
||||
editor,
|
||||
@@ -1480,7 +1467,7 @@ describe("textWysiwyg", () => {
|
||||
// Bind first text
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
let editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello!");
|
||||
expect(
|
||||
@@ -1505,7 +1492,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor = await getTextEditor(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Excalidraw");
|
||||
editor.blur();
|
||||
@@ -1519,4 +1506,18 @@ describe("textWysiwyg", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-27
@@ -1,7 +1,6 @@
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { MarkNonNullable } from "../utility-types";
|
||||
import { assertNever } from "../utils";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -141,32 +140,17 @@ export const isTextBindableContainer = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isExcalidrawElement = (
|
||||
element: any,
|
||||
): element is ExcalidrawElement => {
|
||||
const type: ExcalidrawElement["type"] | undefined = element?.type;
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
switch (type) {
|
||||
case "text":
|
||||
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 isExcalidrawElement = (element: any): boolean => {
|
||||
return (
|
||||
element?.type === "text" ||
|
||||
element?.type === "diamond" ||
|
||||
element?.type === "rectangle" ||
|
||||
element?.type === "embeddable" ||
|
||||
element?.type === "ellipse" ||
|
||||
element?.type === "arrow" ||
|
||||
element?.type === "freedraw" ||
|
||||
element?.type === "line"
|
||||
);
|
||||
};
|
||||
|
||||
export const hasBoundTextElement = (
|
||||
|
||||
+4
-32
@@ -201,52 +201,24 @@ export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
|
||||
for (const element of elements) {
|
||||
const frameId = isFrameElement(element) ? element.id : element.frameId;
|
||||
if (frameId && !frameElementsMap.has(frameId)) {
|
||||
frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
|
||||
frameElementsMap.set(frameId, getFrameElements(elements, frameId));
|
||||
}
|
||||
}
|
||||
|
||||
return frameElementsMap;
|
||||
};
|
||||
|
||||
export const getFrameChildren = (
|
||||
export const getFrameElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frameId: string,
|
||||
) => allElements.filter((element) => element.frameId === frameId);
|
||||
|
||||
export const getFrameElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
): ExcalidrawFrameElement[] => {
|
||||
return allElements.filter((element) =>
|
||||
isFrameElement(element),
|
||||
) as ExcalidrawFrameElement[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns ExcalidrawFrameElements and non-frame-children elements.
|
||||
*
|
||||
* Considers children as root elements if they point to a frame parent
|
||||
* non-existing in the elements set.
|
||||
*
|
||||
* Considers non-frame bound elements (container or arrow labels) as root.
|
||||
*/
|
||||
export const getRootElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
) => {
|
||||
const frameElements = arrayToMap(getFrameElements(allElements));
|
||||
return allElements.filter(
|
||||
(element) =>
|
||||
frameElements.has(element.id) ||
|
||||
!element.frameId ||
|
||||
!frameElements.has(element.frameId),
|
||||
);
|
||||
};
|
||||
|
||||
export const getElementsInResizingFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
appState: AppState,
|
||||
): ExcalidrawElement[] => {
|
||||
const prevElementsInFrame = getFrameChildren(allElements, frame.id);
|
||||
const prevElementsInFrame = getFrameElements(allElements, frame.id);
|
||||
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
|
||||
|
||||
const elementsCompletelyInFrame = new Set([
|
||||
@@ -477,7 +449,7 @@ export const removeAllElementsFromFrame = (
|
||||
frame: ExcalidrawFrameElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const elementsInFrame = getFrameChildren(allElements, frame.id);
|
||||
const elementsInFrame = getFrameElements(allElements, frame.id);
|
||||
return removeElementsFromFrame(allElements, elementsInFrame, appState);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useLayoutEffect } from "react";
|
||||
import { useState, useRef, useLayoutEffect } from "react";
|
||||
import { useDevice, useExcalidrawContainer } from "../components/App";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
|
||||
@@ -10,17 +10,16 @@ export const useCreatePortalContainer = (opts?: {
|
||||
|
||||
const device = useDevice();
|
||||
const { theme } = useUIAppState();
|
||||
const isMobileRef = useRef(device.isMobile);
|
||||
isMobileRef.current = device.isMobile;
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
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.classList.toggle("excalidraw--mobile", device.isMobile);
|
||||
}
|
||||
}, [div, theme, device.editor.isMobile, opts?.className]);
|
||||
}, [div, device.isMobile]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = opts?.parentSelector
|
||||
@@ -33,6 +32,10 @@ export const useCreatePortalContainer = (opts?: {
|
||||
|
||||
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);
|
||||
|
||||
setDiv(div);
|
||||
@@ -40,7 +43,7 @@ export const useCreatePortalContainer = (opts?: {
|
||||
return () => {
|
||||
container.removeChild(div);
|
||||
};
|
||||
}, [excalidrawContainer, opts?.parentSelector]);
|
||||
}, [excalidrawContainer, theme, opts?.className, opts?.parentSelector]);
|
||||
|
||||
return div;
|
||||
};
|
||||
|
||||
+2
-13
@@ -218,10 +218,7 @@
|
||||
"libraryElementTypeError": {
|
||||
"embeddable": "Embeddable elements cannot be added to the library.",
|
||||
"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": {
|
||||
"selection": "Selection",
|
||||
@@ -242,8 +239,7 @@
|
||||
"embeddable": "Web Embed",
|
||||
"laser": "Laser pointer",
|
||||
"hand": "Hand (panning tool)",
|
||||
"extraTools": "More tools",
|
||||
"mermaidToExcalidraw": "Mermaid to Excalidraw"
|
||||
"extraTools": "More tools"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Canvas actions",
|
||||
@@ -502,12 +498,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"path": "dist/excalidraw.production.min.js",
|
||||
"limit": "325 kB"
|
||||
"limit": "320 kB"
|
||||
},
|
||||
{
|
||||
"path": "dist/excalidraw-assets/locales",
|
||||
@@ -11,6 +11,6 @@
|
||||
{
|
||||
"path": "dist/excalidraw-assets/vendor-*.js",
|
||||
"name": "dist/excalidraw-assets/vendor*.js",
|
||||
"limit": "900 kB"
|
||||
"limit": "30 kB"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -15,40 +15,10 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### Features
|
||||
|
||||
- Support `excalidrawAPI` prop for accessing the Excalidraw API [#7251](https://github.com/excalidraw/excalidraw/pull/7251).
|
||||
|
||||
#### BREAKING CHANGE
|
||||
|
||||
- The `Ref` support has been removed in v0.17.0 so if you are using refs, please update the integration to use the [`excalidrawAPI`](http://localhost:3003/docs/@excalidraw/excalidraw/api/props/excalidraw-api)
|
||||
|
||||
- Additionally `ready` and `readyPromise` from the API have been discontinued. These APIs were found to be superfluous, and as part of the effort to streamline the APIs and maintain simplicity, they were removed in version v0.17.0.
|
||||
|
||||
- Export [`getCommonBounds`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#getcommonbounds) helper from the package [#7247](https://github.com/excalidraw/excalidraw/pull/7247).
|
||||
|
||||
- Support frames via programmatic API [#7205](https://github.com/excalidraw/excalidraw/pull/7205).
|
||||
|
||||
- Export `elementsOverlappingBBox`, `isElementInsideBBox`, `elementPartiallyOverlapsWithOrContainsBBox` helpers for filtering/checking if elements within bounds. [#6727](https://github.com/excalidraw/excalidraw/pull/6727)
|
||||
|
||||
- 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)
|
||||
|
||||
### Build
|
||||
|
||||
- Support Preact [#7255](https://github.com/excalidraw/excalidraw/pull/7255). The host needs to set `process.env.IS_PREACT` to `true`
|
||||
|
||||
When using vite, you will have to make sure the variable process.env.IS_PREACT is available at runtime since Vite removes it by default, so you can update the vite config to ensure its available
|
||||
|
||||
```json
|
||||
define: {
|
||||
"process.env.IS_PREACT": process.env.IS_PREACT,
|
||||
}
|
||||
```
|
||||
|
||||
## 0.16.1 (2023-09-21)
|
||||
|
||||
## Excalidraw Library
|
||||
|
||||
@@ -665,9 +665,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
</div>
|
||||
<div className="excalidraw-wrapper">
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api: ExcalidrawImperativeAPI) =>
|
||||
setExcalidrawAPI(api)
|
||||
}
|
||||
ref={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
onChange={(elements, state) => {
|
||||
console.info("Elements :", elements, "State : ", state);
|
||||
|
||||
@@ -8,7 +8,7 @@ const MobileFooter = ({
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
if (device.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<CustomFooter excalidrawAPI={excalidrawAPI} />
|
||||
|
||||
@@ -7,7 +7,6 @@ const elements: ExcalidrawElementSkeleton[] = [
|
||||
x: 10,
|
||||
y: 10,
|
||||
strokeWidth: 2,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
@@ -20,7 +19,6 @@ const elements: ExcalidrawElementSkeleton[] = [
|
||||
strokeColor: "#099268",
|
||||
fontSize: 30,
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
@@ -38,11 +36,6 @@ const elements: ExcalidrawElementSkeleton[] = [
|
||||
height: 230,
|
||||
fileId: "rocket" as FileId,
|
||||
},
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
name: "My frame",
|
||||
},
|
||||
];
|
||||
export default {
|
||||
elements,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, forwardRef } from "react";
|
||||
import { InitializeApp } from "../../components/InitializeApp";
|
||||
import App from "../../components/App";
|
||||
import { isShallowEqual } from "../../utils";
|
||||
@@ -6,7 +6,7 @@ import { isShallowEqual } from "../../utils";
|
||||
import "../../css/app.scss";
|
||||
import "../../css/styles.scss";
|
||||
|
||||
import { AppProps, ExcalidrawProps } from "../../types";
|
||||
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
|
||||
import { defaultLang } from "../../i18n";
|
||||
import { DEFAULT_UI_OPTIONS } from "../../constants";
|
||||
import { Provider } from "jotai";
|
||||
@@ -20,7 +20,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
const {
|
||||
onChange,
|
||||
initialData,
|
||||
excalidrawAPI,
|
||||
excalidrawRef,
|
||||
isCollaborating = false,
|
||||
onPointerUpdate,
|
||||
renderTopRightUI,
|
||||
@@ -95,7 +95,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
<App
|
||||
onChange={onChange}
|
||||
initialData={initialData}
|
||||
excalidrawAPI={excalidrawAPI}
|
||||
excalidrawRef={excalidrawRef}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={onPointerUpdate}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
@@ -127,7 +127,12 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
|
||||
type PublicExcalidrawProps = Omit<ExcalidrawProps, "forwardedRef">;
|
||||
|
||||
const areEqual = (
|
||||
prevProps: PublicExcalidrawProps,
|
||||
nextProps: PublicExcalidrawProps,
|
||||
) => {
|
||||
// short-circuit early
|
||||
if (prevProps.children !== nextProps.children) {
|
||||
return false;
|
||||
@@ -184,7 +189,12 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
|
||||
return isUIOptionsSame && isShallowEqual(prev, next);
|
||||
};
|
||||
|
||||
export const Excalidraw = React.memo(ExcalidrawBase, areEqual);
|
||||
const forwardedRefComp = forwardRef<
|
||||
ExcalidrawAPIRefValue,
|
||||
PublicExcalidrawProps
|
||||
>((props, ref) => <ExcalidrawBase {...props} excalidrawRef={ref} />);
|
||||
|
||||
export const Excalidraw = React.memo(forwardedRefComp, areEqual);
|
||||
Excalidraw.displayName = "Excalidraw";
|
||||
|
||||
export {
|
||||
@@ -244,7 +254,6 @@ export { DefaultSidebar } from "../../components/DefaultSidebar";
|
||||
|
||||
export { normalizeLink } from "../../data/url";
|
||||
export { convertToExcalidrawElements } from "../../data/transform";
|
||||
export { getCommonBounds } from "../../element/bounds";
|
||||
|
||||
export {
|
||||
elementsOverlappingBBox,
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
if (process.env.IS_PREACT === "true") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
module.exports = require("./dist/excalidraw-with-preact.production.min.js");
|
||||
} else {
|
||||
module.exports = require("./dist/excalidraw-with-preact.development.js");
|
||||
}
|
||||
} else if (process.env.NODE_ENV === "production") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
module.exports = require("./dist/excalidraw.production.min.js");
|
||||
} else {
|
||||
module.exports = require("./dist/excalidraw.development.js");
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/src/packages/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "tsc --project ../../../tsconfig-types.json",
|
||||
"build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && NODE_ENV=development webpack --config webpack.preact.config.js && NODE_ENV=production webpack --config webpack.preact.config.js && yarn gen:types",
|
||||
"build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && yarn gen:types",
|
||||
"build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
|
||||
"pack": "yarn build:umd && yarn pack",
|
||||
"start": "webpack serve --config webpack.dev-server.config.js",
|
||||
|
||||
@@ -41,14 +41,6 @@ module.exports = {
|
||||
"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)$/,
|
||||
exclude:
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const prodConfig = require("./webpack.prod.config");
|
||||
const devConfig = require("./webpack.dev.config");
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
const config = isProd ? prodConfig : devConfig;
|
||||
const outputFile = isProd
|
||||
? "excalidraw-with-preact.production.min"
|
||||
: "excalidraw-with-preact.development";
|
||||
|
||||
const preactWebpackConfig = {
|
||||
entry: {
|
||||
[outputFile]: "./entry.js",
|
||||
},
|
||||
externals: {
|
||||
...config.externals,
|
||||
"react-dom/client": {
|
||||
root: "ReactDOMClient",
|
||||
commonjs2: "react-dom/client",
|
||||
commonjs: "react-dom/client",
|
||||
amd: "react-dom/client",
|
||||
},
|
||||
"react/jsx-runtime": {
|
||||
root: "ReactJSXRuntime",
|
||||
commonjs2: "react/jsx-runtime",
|
||||
commonjs: "react/jsx-runtime",
|
||||
amd: "react/jsx-runtime",
|
||||
},
|
||||
},
|
||||
};
|
||||
module.exports = merge(config, preactWebpackConfig);
|
||||
@@ -44,14 +44,6 @@ module.exports = {
|
||||
"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)$/,
|
||||
exclude:
|
||||
|
||||
+39
-16
@@ -4,11 +4,7 @@ import {
|
||||
} from "../scene/export";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
NonDeleted,
|
||||
} from "../element/types";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { restore } from "../data/restore";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { encodePngMetadata } from "../data/image";
|
||||
@@ -18,6 +14,24 @@ import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
} 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 };
|
||||
|
||||
@@ -26,7 +40,6 @@ type ExportOpts = {
|
||||
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
||||
files: BinaryFiles | null;
|
||||
maxWidthOrHeight?: number;
|
||||
exportingFrame?: ExcalidrawFrameElement | null;
|
||||
getDimensions?: (
|
||||
width: number,
|
||||
height: number,
|
||||
@@ -40,7 +53,6 @@ export const exportToCanvas = ({
|
||||
maxWidthOrHeight,
|
||||
getDimensions,
|
||||
exportPadding,
|
||||
exportingFrame,
|
||||
}: ExportOpts & {
|
||||
exportPadding?: number;
|
||||
}) => {
|
||||
@@ -51,10 +63,10 @@ export const exportToCanvas = ({
|
||||
);
|
||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
||||
return _exportToCanvas(
|
||||
restoredElements,
|
||||
passElementsSafely(restoredElements),
|
||||
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
|
||||
files || {},
|
||||
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
|
||||
{ exportBackground, exportPadding, viewBackgroundColor },
|
||||
(width: number, height: number) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
@@ -123,8 +135,10 @@ export const exportToBlob = async (
|
||||
};
|
||||
}
|
||||
|
||||
const canvas = await exportToCanvas(opts);
|
||||
|
||||
const canvas = await exportToCanvas({
|
||||
...opts,
|
||||
elements: passElementsSafely(opts.elements),
|
||||
});
|
||||
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -165,7 +179,6 @@ export const exportToSvg = async ({
|
||||
files = {},
|
||||
exportPadding,
|
||||
renderEmbeddables,
|
||||
exportingFrame,
|
||||
}: Omit<ExportOpts, "getDimensions"> & {
|
||||
exportPadding?: number;
|
||||
renderEmbeddables?: boolean;
|
||||
@@ -181,10 +194,20 @@ export const exportToSvg = async ({
|
||||
exportPadding,
|
||||
};
|
||||
|
||||
return _exportToSvg(restoredElements, exportAppState, files, {
|
||||
exportingFrame,
|
||||
renderEmbeddables,
|
||||
});
|
||||
return _exportToSvg(
|
||||
passElementsSafely(restoredElements),
|
||||
exportAppState,
|
||||
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 (
|
||||
|
||||
@@ -6,14 +6,13 @@ import type {
|
||||
} 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";
|
||||
import { Bounds } from "../element/bounds";
|
||||
|
||||
type Element = NonDeletedExcalidrawElement;
|
||||
type Elements = readonly NonDeletedExcalidrawElement[];
|
||||
@@ -147,7 +146,7 @@ export const elementsOverlappingBBox = ({
|
||||
errorMargin = 0,
|
||||
}: {
|
||||
elements: Elements;
|
||||
bounds: Bounds | ExcalidrawElement;
|
||||
bounds: Bounds;
|
||||
/** safety offset. Defaults to 0. */
|
||||
errorMargin?: number;
|
||||
/**
|
||||
@@ -157,9 +156,6 @@ export const elementsOverlappingBBox = ({
|
||||
**/
|
||||
type: "overlap" | "contain" | "inside";
|
||||
}) => {
|
||||
if (isExcalidrawElement(bounds)) {
|
||||
bounds = getElementBounds(bounds);
|
||||
}
|
||||
const adjustedBBox: Bounds = [
|
||||
bounds[0] - errorMargin,
|
||||
bounds[1] - errorMargin,
|
||||
|
||||
@@ -20,13 +20,7 @@ import type { Drawable } from "roughjs/bin/core";
|
||||
import type { RoughSVG } from "roughjs/bin/svg";
|
||||
|
||||
import { StaticCanvasRenderConfig } from "../scene/types";
|
||||
import {
|
||||
distance,
|
||||
getFontString,
|
||||
getFontFamilyString,
|
||||
isRTL,
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
||||
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import {
|
||||
@@ -595,7 +589,11 @@ export const renderElement = (
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "frame": {
|
||||
if (appState.frameRendering.enabled && appState.frameRendering.outline) {
|
||||
if (
|
||||
!renderConfig.isExporting &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.outline
|
||||
) {
|
||||
context.save();
|
||||
context.translate(
|
||||
element.x + appState.scrollX,
|
||||
@@ -603,7 +601,7 @@ export const renderElement = (
|
||||
);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.lineWidth = 2 / appState.zoom.value;
|
||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||
|
||||
if (FRAME_STYLE.radius && context.roundRect) {
|
||||
@@ -843,13 +841,10 @@ const maybeWrapNodesInFrameClipPath = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
root: SVGElement,
|
||||
nodes: SVGElement[],
|
||||
frameRendering: AppState["frameRendering"],
|
||||
exportedFrameId?: string | null,
|
||||
) => {
|
||||
if (!frameRendering.enabled || !frameRendering.clip) {
|
||||
return null;
|
||||
}
|
||||
const frame = getContainingFrame(element);
|
||||
if (frame) {
|
||||
if (frame && frame.id === exportedFrameId) {
|
||||
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
|
||||
nodes.forEach((node) => g.appendChild(node));
|
||||
@@ -866,11 +861,9 @@ export const renderElementToSvg = (
|
||||
files: BinaryFiles,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
renderConfig: {
|
||||
exportWithDarkMode: boolean;
|
||||
renderEmbeddables: boolean;
|
||||
frameRendering: AppState["frameRendering"];
|
||||
},
|
||||
exportWithDarkMode?: boolean,
|
||||
exportingFrameId?: string | null,
|
||||
renderEmbeddables?: boolean,
|
||||
) => {
|
||||
const offset = { x: offsetX, y: offsetY };
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
@@ -904,13 +897,6 @@ export const renderElementToSvg = (
|
||||
root = anchorTag;
|
||||
}
|
||||
|
||||
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
|
||||
if (isTestEnv()) {
|
||||
node.setAttribute("data-id", element.id);
|
||||
}
|
||||
root.appendChild(node);
|
||||
};
|
||||
|
||||
const opacity =
|
||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||
|
||||
@@ -945,10 +931,10 @@ export const renderElementToSvg = (
|
||||
element,
|
||||
root,
|
||||
[node],
|
||||
renderConfig.frameRendering,
|
||||
exportingFrameId,
|
||||
);
|
||||
|
||||
addToRoot(g || node, element);
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
break;
|
||||
}
|
||||
case "embeddable": {
|
||||
@@ -971,7 +957,7 @@ export const renderElementToSvg = (
|
||||
offsetY || 0
|
||||
}) rotate(${degree} ${cx} ${cy})`,
|
||||
);
|
||||
addToRoot(node, element);
|
||||
root.appendChild(node);
|
||||
|
||||
const label: ExcalidrawElement =
|
||||
createPlaceholderEmbeddableLabel(element);
|
||||
@@ -982,7 +968,9 @@ export const renderElementToSvg = (
|
||||
files,
|
||||
label.x + offset.x - element.x,
|
||||
label.y + offset.y - element.y,
|
||||
renderConfig,
|
||||
exportWithDarkMode,
|
||||
exportingFrameId,
|
||||
renderEmbeddables,
|
||||
);
|
||||
|
||||
// render embeddable element + iframe
|
||||
@@ -1011,10 +999,7 @@ export const renderElementToSvg = (
|
||||
// if rendering embeddables explicitly disabled or
|
||||
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
|
||||
// replace with a link instead
|
||||
if (
|
||||
renderConfig.renderEmbeddables === false ||
|
||||
embedLink?.type === "document"
|
||||
) {
|
||||
if (renderEmbeddables === false || embedLink?.type === "document") {
|
||||
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
|
||||
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
|
||||
anchorTag.setAttribute("target", "_blank");
|
||||
@@ -1048,7 +1033,8 @@ export const renderElementToSvg = (
|
||||
|
||||
embeddableNode.appendChild(foreignObject);
|
||||
}
|
||||
addToRoot(embeddableNode, element);
|
||||
|
||||
root.appendChild(embeddableNode);
|
||||
break;
|
||||
}
|
||||
case "line":
|
||||
@@ -1133,13 +1119,12 @@ export const renderElementToSvg = (
|
||||
element,
|
||||
root,
|
||||
[group, maskPath],
|
||||
renderConfig.frameRendering,
|
||||
exportingFrameId,
|
||||
);
|
||||
if (g) {
|
||||
addToRoot(g, element);
|
||||
root.appendChild(g);
|
||||
} else {
|
||||
addToRoot(group, element);
|
||||
root.appendChild(group);
|
||||
root.append(maskPath);
|
||||
}
|
||||
break;
|
||||
@@ -1173,10 +1158,10 @@ export const renderElementToSvg = (
|
||||
element,
|
||||
root,
|
||||
[node],
|
||||
renderConfig.frameRendering,
|
||||
exportingFrameId,
|
||||
);
|
||||
|
||||
addToRoot(g || node, element);
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
@@ -1206,10 +1191,7 @@ export const renderElementToSvg = (
|
||||
use.setAttribute("href", `#${symbolId}`);
|
||||
|
||||
// in dark theme, revert the image color filter
|
||||
if (
|
||||
renderConfig.exportWithDarkMode &&
|
||||
fileData.mimeType !== MIME_TYPES.svg
|
||||
) {
|
||||
if (exportWithDarkMode && fileData.mimeType !== MIME_TYPES.svg) {
|
||||
use.setAttribute("filter", IMAGE_INVERT_FILTER);
|
||||
}
|
||||
|
||||
@@ -1245,39 +1227,14 @@ export const renderElementToSvg = (
|
||||
element,
|
||||
root,
|
||||
[g],
|
||||
renderConfig.frameRendering,
|
||||
exportingFrameId,
|
||||
);
|
||||
addToRoot(clipG || g, element);
|
||||
clipG ? root.appendChild(clipG) : root.appendChild(g);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// frames are not rendered and only acts as a container
|
||||
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;
|
||||
}
|
||||
default: {
|
||||
@@ -1331,10 +1288,10 @@ export const renderElementToSvg = (
|
||||
element,
|
||||
root,
|
||||
[node],
|
||||
renderConfig.frameRendering,
|
||||
exportingFrameId,
|
||||
);
|
||||
|
||||
addToRoot(g || node, element);
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
|
||||
+30
-24
@@ -60,7 +60,7 @@ import {
|
||||
TransformHandles,
|
||||
TransformHandleType,
|
||||
} from "../element/transformHandles";
|
||||
import { throttleRAF } from "../utils";
|
||||
import { throttleRAF, isOnlyExportingSingleFrame } from "../utils";
|
||||
import { UserIdleState } from "../types";
|
||||
import { FRAME_STYLE, THEME_FILTER } from "../constants";
|
||||
import {
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
isEmbeddableOrLabel,
|
||||
isEmbeddableOrFrameLabel,
|
||||
createPlaceholderEmbeddableLabel,
|
||||
} from "../element/embeddable";
|
||||
import {
|
||||
@@ -369,7 +369,7 @@ const frameClip = (
|
||||
) => {
|
||||
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
|
||||
context.beginPath();
|
||||
if (context.roundRect) {
|
||||
if (context.roundRect && !renderConfig.isExporting) {
|
||||
context.roundRect(
|
||||
0,
|
||||
0,
|
||||
@@ -963,15 +963,20 @@ const _renderStaticScene = ({
|
||||
|
||||
// Paint visible elements
|
||||
visibleElements
|
||||
.filter((el) => !isEmbeddableOrLabel(el))
|
||||
.filter((el) => !isEmbeddableOrFrameLabel(el))
|
||||
.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 (
|
||||
frameId &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip
|
||||
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
|
||||
(!renderConfig.isExporting &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip))
|
||||
) {
|
||||
context.save();
|
||||
|
||||
@@ -996,7 +1001,7 @@ const _renderStaticScene = ({
|
||||
|
||||
// render embeddables on top
|
||||
visibleElements
|
||||
.filter((el) => isEmbeddableOrLabel(el))
|
||||
.filter((el) => isEmbeddableOrFrameLabel(el))
|
||||
.forEach((element) => {
|
||||
try {
|
||||
const render = () => {
|
||||
@@ -1022,8 +1027,10 @@ const _renderStaticScene = ({
|
||||
|
||||
if (
|
||||
frameId &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip
|
||||
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
|
||||
(!renderConfig.isExporting &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip))
|
||||
) {
|
||||
context.save();
|
||||
|
||||
@@ -1291,7 +1298,7 @@ const renderFrameHighlight = (
|
||||
const height = y2 - y1;
|
||||
|
||||
context.strokeStyle = "rgb(0,118,255)";
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / appState.zoom.value;
|
||||
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
@@ -1447,29 +1454,24 @@ export const renderSceneToSvg = (
|
||||
{
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
exportWithDarkMode,
|
||||
exportWithDarkMode = false,
|
||||
exportingFrameId = null,
|
||||
renderEmbeddables,
|
||||
frameRendering,
|
||||
}: {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
exportWithDarkMode: boolean;
|
||||
renderEmbeddables: boolean;
|
||||
frameRendering: AppState["frameRendering"];
|
||||
},
|
||||
exportWithDarkMode?: boolean;
|
||||
exportingFrameId?: string | null;
|
||||
renderEmbeddables?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
if (!svgRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderConfig = {
|
||||
exportWithDarkMode,
|
||||
renderEmbeddables,
|
||||
frameRendering,
|
||||
};
|
||||
// render elements
|
||||
elements
|
||||
.filter((el) => !isEmbeddableOrLabel(el))
|
||||
.filter((el) => !isEmbeddableOrFrameLabel(el))
|
||||
.forEach((element) => {
|
||||
if (!element.isDeleted) {
|
||||
try {
|
||||
@@ -1480,7 +1482,9 @@ export const renderSceneToSvg = (
|
||||
files,
|
||||
element.x + offsetX,
|
||||
element.y + offsetY,
|
||||
renderConfig,
|
||||
exportWithDarkMode,
|
||||
exportingFrameId,
|
||||
renderEmbeddables,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@@ -1501,7 +1505,9 @@ export const renderSceneToSvg = (
|
||||
files,
|
||||
element.x + offsetX,
|
||||
element.y + offsetY,
|
||||
renderConfig,
|
||||
exportWithDarkMode,
|
||||
exportingFrameId,
|
||||
renderEmbeddables,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
+5
-21
@@ -66,29 +66,16 @@ class Scene {
|
||||
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
|
||||
private static sceneMapById = new Map<string, Scene>();
|
||||
|
||||
static mapElementToScene(
|
||||
elementKey: ElementKey,
|
||||
scene: Scene,
|
||||
/**
|
||||
* needed because of frame exporting hack.
|
||||
* elementId:Scene mapping will be removed completely, soon.
|
||||
*/
|
||||
mapElementIds = true,
|
||||
) {
|
||||
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
|
||||
if (isIdKey(elementKey)) {
|
||||
if (!mapElementIds) {
|
||||
return;
|
||||
}
|
||||
// for cases where we don't have access to the element object
|
||||
// (e.g. restore serialized appState with id references)
|
||||
this.sceneMapById.set(elementKey, scene);
|
||||
} else {
|
||||
this.sceneMapByElement.set(elementKey, scene);
|
||||
if (!mapElementIds) {
|
||||
// if mapping element objects, also cache the id string when later
|
||||
// looking up by id alone
|
||||
this.sceneMapById.set(elementKey.id, scene);
|
||||
}
|
||||
// if mapping element objects, also cache the id string when later
|
||||
// looking up by id alone
|
||||
this.sceneMapById.set(elementKey.id, scene);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +217,7 @@ class Scene {
|
||||
return didChange;
|
||||
}
|
||||
|
||||
replaceAllElements(
|
||||
nextElements: readonly ExcalidrawElement[],
|
||||
mapElementIds = true,
|
||||
) {
|
||||
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
|
||||
this.elements = nextElements;
|
||||
const nextFrames: ExcalidrawFrameElement[] = [];
|
||||
this.elementsMap.clear();
|
||||
|
||||
+8
-21
@@ -14,34 +14,18 @@ import { generateFreeDrawShape } from "../renderer/renderElement";
|
||||
import { isTransparent, assertNever } from "../utils";
|
||||
import { simplify } from "points-on-curve";
|
||||
import { ROUGHNESS } from "../constants";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
|
||||
function adjustRoughness(element: ExcalidrawElement): number {
|
||||
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)
|
||||
) {
|
||||
function adjustRoughness(size: number, roughness: number): number {
|
||||
if (size >= 50) {
|
||||
return roughness;
|
||||
}
|
||||
const factor = 2 + (50 - size) / 10;
|
||||
|
||||
return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
|
||||
return roughness / factor;
|
||||
}
|
||||
|
||||
export const generateRoughOptions = (
|
||||
@@ -70,7 +54,10 @@ export const generateRoughOptions = (
|
||||
// calculate them (and we don't want the fills to be modified)
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: adjustRoughness(element),
|
||||
roughness: adjustRoughness(
|
||||
Math.min(element.width, element.height),
|
||||
element.roughness,
|
||||
),
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices:
|
||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||
|
||||
+76
-254
@@ -1,175 +1,23 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
Bounds,
|
||||
getCommonBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "../element/bounds";
|
||||
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
|
||||
import { cloneJSON, distance, getFontString } from "../utils";
|
||||
import { distance, isOnlyExportingSingleFrame } from "../utils";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
FONT_FAMILY,
|
||||
FRAME_STYLE,
|
||||
SVG_NS,
|
||||
THEME_FILTER,
|
||||
} from "../constants";
|
||||
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { serializeAsJSON } from "../data/json";
|
||||
import {
|
||||
getInitializedImageElements,
|
||||
updateImageCache,
|
||||
} from "../element/image";
|
||||
import { elementsOverlappingBBox } from "../packages/withinBounds";
|
||||
import { getFrameElements, getRootElements } from "../frame";
|
||||
import { isFrameElement, newTextElement } from "../element";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import Scene from "./Scene";
|
||||
|
||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
// getContainerElement and getBoundTextElement and potentially other helpers
|
||||
// depend on `Scene` which will not be available when these pure utils are
|
||||
// called outside initialized Excalidraw editor instance or even if called
|
||||
// from inside Excalidraw if the elements were never cached by Scene (e.g.
|
||||
// for library elements).
|
||||
//
|
||||
// As such, before passing the elements down, we need to initialize a custom
|
||||
// Scene instance and assign them to it.
|
||||
//
|
||||
// FIXME This is a super hacky workaround and we'll need to rewrite this soon.
|
||||
const __createSceneForElementsHack__ = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const scene = new Scene();
|
||||
// we can't duplicate elements to regenerate ids because we need the
|
||||
// orig ids when embedding. So we do another hack of not mapping element
|
||||
// ids to Scene instances so that we don't override the editor elements
|
||||
// mapping.
|
||||
// We still need to clone the objects themselves to regen references.
|
||||
scene.replaceAllElements(cloneJSON(elements), false);
|
||||
return scene;
|
||||
};
|
||||
|
||||
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
||||
if (element.width <= maxWidth) {
|
||||
return element;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.font = getFontString({
|
||||
fontFamily: element.fontFamily,
|
||||
fontSize: element.fontSize,
|
||||
});
|
||||
|
||||
let text = element.text;
|
||||
|
||||
const metrics = ctx.measureText(text);
|
||||
|
||||
if (metrics.width > maxWidth) {
|
||||
// we iterate from the right, removing characters one by one instead
|
||||
// of bulding the string up. This assumes that it's more likely
|
||||
// your frame names will overflow by not that many characters
|
||||
// (if ever), so it sohuld be faster this way.
|
||||
for (let i = text.length; i > 0; i--) {
|
||||
const newText = `${text.slice(0, i)}...`;
|
||||
if (ctx.measureText(newText).width <= maxWidth) {
|
||||
text = newText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newElementWith(element, { text, width: maxWidth });
|
||||
};
|
||||
|
||||
/**
|
||||
* When exporting frames, we need to render frame labels which are currently
|
||||
* being rendered in DOM when editing. Adding the labels as regular text
|
||||
* elements seems like a simple hack. In the future we'll want to move to
|
||||
* proper canvas rendering, even within editor (instead of DOM).
|
||||
*/
|
||||
const addFrameLabelsAsTextElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
opts: Pick<AppState, "exportWithDarkMode">,
|
||||
) => {
|
||||
const nextElements: NonDeletedExcalidrawElement[] = [];
|
||||
let frameIdx = 0;
|
||||
for (const element of elements) {
|
||||
if (isFrameElement(element)) {
|
||||
frameIdx++;
|
||||
let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
|
||||
x: element.x,
|
||||
y: element.y - FRAME_STYLE.nameOffsetY,
|
||||
fontFamily: FONT_FAMILY.Assistant,
|
||||
fontSize: FRAME_STYLE.nameFontSize,
|
||||
lineHeight:
|
||||
FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"],
|
||||
strokeColor: opts.exportWithDarkMode
|
||||
? FRAME_STYLE.nameColorDarkTheme
|
||||
: FRAME_STYLE.nameColorLightTheme,
|
||||
text: element.name || `Frame ${frameIdx}`,
|
||||
});
|
||||
textElement.y -= textElement.height;
|
||||
|
||||
textElement = truncateText(textElement, element.width);
|
||||
|
||||
nextElements.push(textElement);
|
||||
}
|
||||
nextElements.push(element);
|
||||
}
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
|
||||
const getFrameRenderingConfig = (
|
||||
exportingFrame: ExcalidrawFrameElement | null,
|
||||
frameRendering: AppState["frameRendering"] | null,
|
||||
): AppState["frameRendering"] => {
|
||||
frameRendering = frameRendering || getDefaultAppState().frameRendering;
|
||||
return {
|
||||
enabled: exportingFrame ? true : frameRendering.enabled,
|
||||
outline: exportingFrame ? false : frameRendering.outline,
|
||||
name: exportingFrame ? false : frameRendering.name,
|
||||
clip: exportingFrame ? true : frameRendering.clip,
|
||||
};
|
||||
};
|
||||
|
||||
const prepareElementsForRender = ({
|
||||
elements,
|
||||
exportingFrame,
|
||||
frameRendering,
|
||||
exportWithDarkMode,
|
||||
}: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
exportingFrame: ExcalidrawFrameElement | null | undefined;
|
||||
frameRendering: AppState["frameRendering"];
|
||||
exportWithDarkMode: AppState["exportWithDarkMode"];
|
||||
}) => {
|
||||
let nextElements: readonly ExcalidrawElement[];
|
||||
|
||||
if (exportingFrame) {
|
||||
nextElements = elementsOverlappingBBox({
|
||||
elements,
|
||||
bounds: exportingFrame,
|
||||
type: "overlap",
|
||||
});
|
||||
} else if (frameRendering.enabled && frameRendering.name) {
|
||||
nextElements = addFrameLabelsAsTextElements(elements, {
|
||||
exportWithDarkMode,
|
||||
});
|
||||
} else {
|
||||
nextElements = elements;
|
||||
}
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
export const exportToCanvas = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -179,12 +27,10 @@ export const exportToCanvas = async (
|
||||
exportBackground,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
viewBackgroundColor,
|
||||
exportingFrame,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
exportingFrame?: ExcalidrawFrameElement | null;
|
||||
},
|
||||
createCanvas: (
|
||||
width: number,
|
||||
@@ -196,29 +42,7 @@ export const exportToCanvas = async (
|
||||
return { canvas, scale: appState.exportScale };
|
||||
},
|
||||
) => {
|
||||
const tempScene = __createSceneForElementsHack__(elements);
|
||||
elements = tempScene.getNonDeletedElements();
|
||||
|
||||
const frameRendering = getFrameRenderingConfig(
|
||||
exportingFrame ?? null,
|
||||
appState.frameRendering ?? null,
|
||||
);
|
||||
|
||||
const elementsForRender = prepareElementsForRender({
|
||||
elements,
|
||||
exportingFrame,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
frameRendering,
|
||||
});
|
||||
|
||||
if (exportingFrame) {
|
||||
exportPadding = 0;
|
||||
}
|
||||
|
||||
const [minX, minY, width, height] = getCanvasSize(
|
||||
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
||||
exportPadding,
|
||||
);
|
||||
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
|
||||
|
||||
const { canvas, scale = 1 } = createCanvas(width, height);
|
||||
|
||||
@@ -226,24 +50,25 @@ export const exportToCanvas = async (
|
||||
|
||||
const { imageCache } = await updateImageCache({
|
||||
imageCache: new Map(),
|
||||
fileIds: getInitializedImageElements(elementsForRender).map(
|
||||
fileIds: getInitializedImageElements(elements).map(
|
||||
(element) => element.fileId,
|
||||
),
|
||||
files,
|
||||
});
|
||||
|
||||
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
||||
|
||||
renderStaticScene({
|
||||
canvas,
|
||||
rc: rough.canvas(canvas),
|
||||
elements: elementsForRender,
|
||||
visibleElements: elementsForRender,
|
||||
elements,
|
||||
visibleElements: elements,
|
||||
scale,
|
||||
appState: {
|
||||
...appState,
|
||||
frameRendering,
|
||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||
scrollX: -minX + exportPadding,
|
||||
scrollY: -minY + exportPadding,
|
||||
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
|
||||
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
|
||||
zoom: defaultAppState.zoom,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
theme: appState.exportWithDarkMode ? "dark" : "light",
|
||||
@@ -255,8 +80,6 @@ export const exportToCanvas = async (
|
||||
},
|
||||
});
|
||||
|
||||
tempScene.destroy();
|
||||
|
||||
return canvas;
|
||||
};
|
||||
|
||||
@@ -269,67 +92,35 @@ export const exportToSvg = async (
|
||||
viewBackgroundColor: string;
|
||||
exportWithDarkMode?: boolean;
|
||||
exportEmbedScene?: boolean;
|
||||
frameRendering?: AppState["frameRendering"];
|
||||
renderFrame?: boolean;
|
||||
},
|
||||
files: BinaryFiles | null,
|
||||
opts?: {
|
||||
serializeAsJSON?: () => string;
|
||||
renderEmbeddables?: boolean;
|
||||
exportingFrame?: ExcalidrawFrameElement | null;
|
||||
},
|
||||
): Promise<SVGSVGElement> => {
|
||||
const tempScene = __createSceneForElementsHack__(elements);
|
||||
elements = tempScene.getNonDeletedElements();
|
||||
|
||||
const frameRendering = getFrameRenderingConfig(
|
||||
opts?.exportingFrame ?? null,
|
||||
appState.frameRendering ?? null,
|
||||
);
|
||||
|
||||
let {
|
||||
const {
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
exportWithDarkMode = false,
|
||||
viewBackgroundColor,
|
||||
exportScale = 1,
|
||||
exportEmbedScene,
|
||||
} = appState;
|
||||
|
||||
const { exportingFrame = null } = opts || {};
|
||||
|
||||
const elementsForRender = prepareElementsForRender({
|
||||
elements,
|
||||
exportingFrame,
|
||||
exportWithDarkMode,
|
||||
frameRendering,
|
||||
});
|
||||
|
||||
if (exportingFrame) {
|
||||
exportPadding = 0;
|
||||
}
|
||||
|
||||
let metadata = "";
|
||||
|
||||
// we need to serialize the "original" elements before we put them through
|
||||
// the tempScene hack which duplicates and regenerates ids
|
||||
if (exportEmbedScene) {
|
||||
try {
|
||||
metadata = await (
|
||||
await import(/* webpackChunkName: "image" */ "../../src/data/image")
|
||||
).encodeSvgMetadata({
|
||||
// when embedding scene, we want to embed the origionally supplied
|
||||
// elements which don't contain the temp frame labels.
|
||||
// But it also requires that the exportToSvg is being supplied with
|
||||
// only the elements that we're exporting, and no extra.
|
||||
text: serializeAsJSON(elements, appState, files || {}, "local"),
|
||||
text: opts?.serializeAsJSON
|
||||
? opts?.serializeAsJSON?.()
|
||||
: serializeAsJSON(elements, appState, files || {}, "local"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const [minX, minY, width, height] = getCanvasSize(
|
||||
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
||||
exportPadding,
|
||||
);
|
||||
const [minX, minY, width, height] = getCanvasSize(elements, exportPadding);
|
||||
|
||||
// initialize SVG root
|
||||
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
||||
@@ -338,7 +129,7 @@ export const exportToSvg = async (
|
||||
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
svgRoot.setAttribute("width", `${width * exportScale}`);
|
||||
svgRoot.setAttribute("height", `${height * exportScale}`);
|
||||
if (exportWithDarkMode) {
|
||||
if (appState.exportWithDarkMode) {
|
||||
svgRoot.setAttribute("filter", THEME_FILTER);
|
||||
}
|
||||
|
||||
@@ -357,23 +148,33 @@ export const exportToSvg = async (
|
||||
assetPath = `${assetPath}/dist/excalidraw-assets/`;
|
||||
}
|
||||
|
||||
const offsetX = -minX + exportPadding;
|
||||
const offsetY = -minY + exportPadding;
|
||||
// do not apply clipping when we're exporting the whole scene
|
||||
const isExportingWholeCanvas =
|
||||
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
|
||||
elements.length;
|
||||
|
||||
const frameElements = getFrameElements(elements);
|
||||
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
||||
|
||||
const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
|
||||
const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
|
||||
|
||||
const exportingFrame =
|
||||
isExportingWholeCanvas || !onlyExportingSingleFrame
|
||||
? undefined
|
||||
: elements.find((element) => element.type === "frame");
|
||||
|
||||
let exportingFrameClipPath = "";
|
||||
for (const frame of frameElements) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
|
||||
const cx = (x2 - x1) / 2 - (frame.x - x1);
|
||||
const cy = (y2 - y1) / 2 - (frame.y - y1);
|
||||
if (exportingFrame) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame);
|
||||
const cx = (x2 - x1) / 2 - (exportingFrame.x - x1);
|
||||
const cy = (y2 - y1) / 2 - (exportingFrame.y - y1);
|
||||
|
||||
exportingFrameClipPath += `<clipPath id=${frame.id}>
|
||||
<rect transform="translate(${frame.x + offsetX} ${
|
||||
frame.y + offsetY
|
||||
}) rotate(${frame.angle} ${cx} ${cy})"
|
||||
width="${frame.width}"
|
||||
height="${frame.height}"
|
||||
exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
|
||||
<rect transform="translate(${exportingFrame.x + offsetX} ${
|
||||
exportingFrame.y + offsetY
|
||||
}) rotate(${exportingFrame.angle} ${cx} ${cy})"
|
||||
width="${exportingFrame.width}"
|
||||
height="${exportingFrame.height}"
|
||||
>
|
||||
</rect>
|
||||
</clipPath>`;
|
||||
@@ -392,10 +193,6 @@ export const exportToSvg = async (
|
||||
font-family: "Cascadia";
|
||||
src: url("${assetPath}Cascadia.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Assistant";
|
||||
src: url("${assetPath}Assistant-Regular.woff2");
|
||||
}
|
||||
</style>
|
||||
${exportingFrameClipPath}
|
||||
</defs>
|
||||
@@ -413,16 +210,14 @@ export const exportToSvg = async (
|
||||
}
|
||||
|
||||
const rsvg = rough.svg(svgRoot);
|
||||
renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, {
|
||||
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
||||
offsetX,
|
||||
offsetY,
|
||||
exportWithDarkMode,
|
||||
renderEmbeddables: opts?.renderEmbeddables ?? false,
|
||||
frameRendering,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
exportingFrameId: exportingFrame?.id || null,
|
||||
renderEmbeddables: opts?.renderEmbeddables,
|
||||
});
|
||||
|
||||
tempScene.destroy();
|
||||
|
||||
return svgRoot;
|
||||
};
|
||||
|
||||
@@ -431,9 +226,36 @@ const getCanvasSize = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
exportPadding: number,
|
||||
): Bounds => {
|
||||
// we should decide if we are exporting the whole canvas
|
||||
// if so, we are not clipping elements in the frame
|
||||
// and therefore, we should not do anything special
|
||||
|
||||
const isExportingWholeCanvas =
|
||||
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
|
||||
elements.length;
|
||||
|
||||
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
||||
|
||||
if (!isExportingWholeCanvas || onlyExportingSingleFrame) {
|
||||
const frames = elements.filter((element) => element.type === "frame");
|
||||
|
||||
const exportedFrameIds = frames.reduce((acc, frame) => {
|
||||
acc[frame.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, true>);
|
||||
|
||||
// elements in a frame do not affect the canvas size if we're not exporting
|
||||
// the whole canvas
|
||||
elements = elements.filter(
|
||||
(element) => !exportedFrameIds[element.frameId ?? ""],
|
||||
);
|
||||
}
|
||||
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
const width = distance(minX, maxX) + exportPadding * 2;
|
||||
const height = distance(minY, maxY) + exportPadding * 2;
|
||||
const width =
|
||||
distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
||||
const height =
|
||||
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
||||
|
||||
return [minX, minY, width, height];
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
getFrameElements,
|
||||
} from "../frame";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { isElementInViewport } from "../element/sizeHelpers";
|
||||
@@ -191,7 +191,7 @@ export const getSelectedElements = (
|
||||
const elementsToInclude: ExcalidrawElement[] = [];
|
||||
selectedElements.forEach((element) => {
|
||||
if (element.type === "frame") {
|
||||
getFrameChildren(elements, element.id).forEach((e) =>
|
||||
getFrameElements(elements, element.id).forEach((e) =>
|
||||
elementsToInclude.push(e),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ import "vitest-canvas-mock";
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import polyfill from "./polyfill";
|
||||
import { testPolyfills } from "./tests/helpers/polyfills";
|
||||
|
||||
Object.assign(globalThis, testPolyfills);
|
||||
|
||||
require("fake-indexeddb/auto");
|
||||
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { act, fireEvent, render, waitFor } from "./test-utils";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import React from "react";
|
||||
import { expect, vi } from "vitest";
|
||||
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
|
||||
vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => {
|
||||
const module = (await importActual()) as any;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...module,
|
||||
};
|
||||
});
|
||||
const parseMermaidToExcalidrawSpy = vi.spyOn(
|
||||
MermaidToExcalidraw,
|
||||
"parseMermaidToExcalidraw",
|
||||
);
|
||||
|
||||
parseMermaidToExcalidrawSpy.mockImplementation(
|
||||
async (
|
||||
definition: string,
|
||||
options?: MermaidToExcalidraw.MermaidOptions | undefined,
|
||||
) => {
|
||||
const firstLine = definition.split("\n")[0];
|
||||
return new Promise((resolve, reject) => {
|
||||
if (firstLine === "flowchart TD") {
|
||||
resolve({
|
||||
elements: [
|
||||
{
|
||||
id: "Start",
|
||||
type: "rectangle",
|
||||
groupIds: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 69.703125,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: [],
|
||||
text: "Start",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
id: "Stop",
|
||||
type: "rectangle",
|
||||
groupIds: [],
|
||||
x: 2.7109375,
|
||||
y: 94,
|
||||
width: 64.28125,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: [],
|
||||
text: "Stop",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
id: "Start_Stop",
|
||||
type: "arrow",
|
||||
groupIds: [],
|
||||
x: 34.852,
|
||||
y: 44,
|
||||
strokeWidth: 2,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, 50],
|
||||
],
|
||||
roundness: {
|
||||
type: 2,
|
||||
},
|
||||
start: {
|
||||
id: "Start",
|
||||
},
|
||||
end: {
|
||||
id: "Stop",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
reject(new Error("ERROR"));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(React, "useRef").mockReturnValue({
|
||||
current: {
|
||||
parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
|
||||
},
|
||||
});
|
||||
|
||||
describe("Test <MermaidToExcalidraw/>", () => {
|
||||
beforeEach(async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
openDialog: "mermaid",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
it("should open mermaid popup when active tool is mermaid", async () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
await waitFor(() => dialog.querySelector("canvas"));
|
||||
expect(dialog.outerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should close the popup and set the tool to selection when close button clicked", () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
const closeBtn = dialog.querySelector(".Dialog__close")!;
|
||||
fireEvent.click(closeBtn);
|
||||
expect(document.querySelector(".dialog-mermaid")).toBe(null);
|
||||
expect(window.h.state.activeTool).toStrictEqual({
|
||||
customType: null,
|
||||
lastActiveTool: null,
|
||||
locked: false,
|
||||
type: "selection",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error in preview when mermaid library throws error", async () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
const selector = ".dialog-mermaid-panels-text textarea";
|
||||
let editor = await getTextEditor(selector, false);
|
||||
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
|
||||
|
||||
expect(editor.textContent).toMatchInlineSnapshot(`
|
||||
"flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[Car]"
|
||||
`);
|
||||
|
||||
await act(async () => {
|
||||
updateTextEditor(editor, "flowchart TD1");
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
});
|
||||
editor = await getTextEditor(selector, false);
|
||||
|
||||
expect(editor.textContent).toBe("flowchart TD1");
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]'))
|
||||
.toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="mermaid-error"
|
||||
data-testid="mermaid-error"
|
||||
>
|
||||
Error!
|
||||
<p>
|
||||
ERROR
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
|
||||
"<div class=\\"Modal Dialog dialog-mermaid\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><h2 id=\\"test-id-dialog-title\\" class=\\"Dialog__title\\"><span class=\\"Dialog__titleContent\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><span class=\\"dialog-mermaid-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.<br></span></span></h2><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div class=\\"dialog-mermaid-body\\"><div class=\\"dialog-mermaid-panels\\"><div class=\\"dialog-mermaid-panels-text\\"><label>Mermaid Syntax</label><textarea>flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[Car]</textarea></div><div class=\\"dialog-mermaid-panels-preview\\"><label>Preview</label><div class=\\"dialog-mermaid-panels-preview-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"dialog-mermaid-panels-preview-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div></div></div><div class=\\"dialog-mermaid-buttons\\"><button type=\\"button\\" class=\\"excalidraw-button dialog-mermaid-insert\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></button></div></div></div></div></div></div>"
|
||||
`;
|
||||
@@ -17,6 +17,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"keyTest": [Function],
|
||||
"name": "cut",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -26,6 +27,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"keyTest": undefined,
|
||||
"name": "copy",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -35,6 +37,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"keyTest": undefined,
|
||||
"name": "paste",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -4601,6 +4604,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"keyTest": [Function],
|
||||
"name": "cut",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -4610,6 +4614,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"keyTest": undefined,
|
||||
"name": "copy",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -4619,6 +4624,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"keyTest": undefined,
|
||||
"name": "paste",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -5181,6 +5187,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"keyTest": [Function],
|
||||
"name": "cut",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -5190,6 +5197,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"keyTest": undefined,
|
||||
"name": "copy",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -5199,6 +5207,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"keyTest": undefined,
|
||||
"name": "paste",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -5846,6 +5855,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"keyTest": undefined,
|
||||
"name": "paste",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -6099,6 +6109,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"keyTest": [Function],
|
||||
"name": "cut",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -6108,6 +6119,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"keyTest": undefined,
|
||||
"name": "copy",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -6117,6 +6129,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"keyTest": undefined,
|
||||
"name": "paste",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -6473,6 +6486,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"keyTest": [Function],
|
||||
"name": "cut",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -6482,6 +6496,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"keyTest": undefined,
|
||||
"name": "copy",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
@@ -6491,6 +6506,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"keyTest": undefined,
|
||||
"name": "paste",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
|
||||
@@ -14,12 +14,8 @@ exports[`export > exporting svg containing transformed images > svg export outpu
|
||||
font-family: \\"Cascadia\\";
|
||||
src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
|
||||
}
|
||||
@font-face {
|
||||
font-family: \\"Assistant\\";
|
||||
src: url(\\"https://excalidraw.com/Assistant-Regular.woff2\\");
|
||||
}
|
||||
</style>
|
||||
|
||||
</defs>
|
||||
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\" data-id=\\"id1\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\" data-id=\\"id2\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\" data-id=\\"id3\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\" data-id=\\"id4\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
|
||||
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { vi } from "vitest";
|
||||
import ReactDOM from "react-dom";
|
||||
import { render, waitFor, GlobalTestState } from "./test-utils";
|
||||
import {
|
||||
render,
|
||||
waitFor,
|
||||
GlobalTestState,
|
||||
createPasteEvent,
|
||||
} from "./test-utils";
|
||||
import { Pointer, Keyboard } from "./helpers/ui";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import { KEYS } from "../keys";
|
||||
@@ -11,7 +16,7 @@ import {
|
||||
import { getElementBounds } from "../element";
|
||||
import { NormalizedZoomValue } from "../types";
|
||||
import { API } from "./helpers/api";
|
||||
import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -32,9 +37,7 @@ vi.mock("../keys.ts", async (importOriginal) => {
|
||||
|
||||
const sendPasteEvent = (text: string) => {
|
||||
const clipboardEvent = createPasteEvent({
|
||||
types: {
|
||||
"text/plain": text,
|
||||
},
|
||||
"text/plain": text,
|
||||
});
|
||||
document.dispatchEvent(clipboardEvent);
|
||||
};
|
||||
@@ -83,10 +86,7 @@ beforeEach(async () => {
|
||||
describe("general paste behavior", () => {
|
||||
it("should randomize seed on paste", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rectangle],
|
||||
files: null,
|
||||
});
|
||||
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -97,10 +97,7 @@ describe("general paste behavior", () => {
|
||||
|
||||
it("should retain seed on shift-paste", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const clipboardJSON = await serializeAsClipboardJSON({
|
||||
elements: [rectangle],
|
||||
files: null,
|
||||
});
|
||||
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
|
||||
|
||||
// assert we don't randomize seed on shift-paste
|
||||
pasteWithCtrlCmdShiftV(clipboardJSON);
|
||||
|
||||
@@ -83,7 +83,6 @@ describe("contextMenu element", () => {
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"paste",
|
||||
"selectAll",
|
||||
"gridMode",
|
||||
"zenMode",
|
||||
@@ -115,9 +114,6 @@ describe("contextMenu element", () => {
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
@@ -207,9 +203,6 @@ describe("contextMenu element", () => {
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
@@ -263,9 +256,6 @@ describe("contextMenu element", () => {
|
||||
const contextMenuOptions =
|
||||
contextMenu?.querySelectorAll(".context-menu li");
|
||||
const expectedShortcutNames: ShortcutName[] = [
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
|
||||
+13
-10
@@ -1,5 +1,6 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import {
|
||||
createPasteEvent,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
render,
|
||||
@@ -26,8 +27,6 @@ import { vi } from "vitest";
|
||||
import * as blob from "../data/blob";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElementPosition } from "../element/textElement";
|
||||
import { createPasteEvent } from "../clipboard";
|
||||
import { cloneJSON } from "../utils";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@@ -207,14 +206,16 @@ const checkElementsBoundingBox = async (
|
||||
};
|
||||
|
||||
const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
const newElement = h.elements[0];
|
||||
await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
|
||||
};
|
||||
|
||||
const checkTwoPointsLineHorizontalFlip = async () => {
|
||||
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
|
||||
const originalElement = JSON.parse(
|
||||
JSON.stringify(h.elements[0]),
|
||||
) as ExcalidrawLinearElement;
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
const newElement = h.elements[0] as ExcalidrawLinearElement;
|
||||
await waitFor(() => {
|
||||
@@ -238,7 +239,9 @@ const checkTwoPointsLineHorizontalFlip = async () => {
|
||||
};
|
||||
|
||||
const checkTwoPointsLineVerticalFlip = async () => {
|
||||
const originalElement = cloneJSON(h.elements[0]) as ExcalidrawLinearElement;
|
||||
const originalElement = JSON.parse(
|
||||
JSON.stringify(h.elements[0]),
|
||||
) as ExcalidrawLinearElement;
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
const newElement = h.elements[0] as ExcalidrawLinearElement;
|
||||
await waitFor(() => {
|
||||
@@ -265,7 +268,7 @@ const checkRotatedHorizontalFlip = async (
|
||||
expectedAngle: number,
|
||||
toleranceInPx: number = 0.00001,
|
||||
) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
const newElement = h.elements[0];
|
||||
await waitFor(() => {
|
||||
@@ -278,7 +281,7 @@ const checkRotatedVerticalFlip = async (
|
||||
expectedAngle: number,
|
||||
toleranceInPx: number = 0.00001,
|
||||
) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
const newElement = h.elements[0];
|
||||
await waitFor(() => {
|
||||
@@ -288,7 +291,7 @@ const checkRotatedVerticalFlip = async (
|
||||
};
|
||||
|
||||
const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
||||
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
|
||||
@@ -297,7 +300,7 @@ const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||
};
|
||||
|
||||
const checkVerticalHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
|
||||
const originalElement = cloneJSON(h.elements[0]);
|
||||
const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
|
||||
|
||||
h.app.actionManager.executeAction(actionFlipHorizontal);
|
||||
h.app.actionManager.executeAction(actionFlipVertical);
|
||||
@@ -724,7 +727,7 @@ describe("freedraw", () => {
|
||||
describe("image", () => {
|
||||
const createImage = async () => {
|
||||
const sendPasteEvent = (file?: File) => {
|
||||
const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });
|
||||
const clipboardEvent = createPasteEvent({}, file ? [file] : []);
|
||||
document.dispatchEvent(clipboardEvent);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
FileId,
|
||||
ExcalidrawFrameElement,
|
||||
} from "../../element/types";
|
||||
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
||||
@@ -137,8 +136,6 @@ export class API {
|
||||
? ExcalidrawTextElement
|
||||
: T extends "image"
|
||||
? ExcalidrawImageElement
|
||||
: T extends "frame"
|
||||
? ExcalidrawFrameElement
|
||||
: ExcalidrawGenericElement => {
|
||||
let element: Mutable<ExcalidrawElement> = null!;
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
class ClipboardEvent {
|
||||
constructor(
|
||||
type: "paste" | "copy",
|
||||
eventInitDict: {
|
||||
clipboardData: DataTransfer;
|
||||
},
|
||||
) {
|
||||
return Object.assign(
|
||||
new Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
{
|
||||
clipboardData: eventInitDict.clipboardData,
|
||||
},
|
||||
) as any as ClipboardEvent;
|
||||
}
|
||||
}
|
||||
|
||||
type DataKind = "string" | "file";
|
||||
|
||||
class DataTransferItem {
|
||||
kind: DataKind;
|
||||
type: string;
|
||||
data: string | Blob;
|
||||
|
||||
constructor(kind: DataKind, type: string, data: string | Blob) {
|
||||
this.kind = kind;
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
getAsString(callback: (data: string) => void): void {
|
||||
if (this.kind === "string") {
|
||||
callback(this.data as string);
|
||||
}
|
||||
}
|
||||
|
||||
getAsFile(): File | null {
|
||||
if (this.kind === "file" && this.data instanceof File) {
|
||||
return this.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DataTransferList {
|
||||
items: DataTransferItem[] = [];
|
||||
|
||||
add(data: string | File, type: string = ""): void {
|
||||
if (typeof data === "string") {
|
||||
this.items.push(new DataTransferItem("string", type, data));
|
||||
} else if (data instanceof File) {
|
||||
this.items.push(new DataTransferItem("file", type, data));
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
class DataTransfer {
|
||||
public items: DataTransferList = new DataTransferList();
|
||||
private _types: Record<string, string> = {};
|
||||
|
||||
get files() {
|
||||
return this.items.items
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile()!);
|
||||
}
|
||||
|
||||
add(data: string | File, type: string = ""): void {
|
||||
this.items.add(data, type);
|
||||
}
|
||||
|
||||
setData(type: string, value: string) {
|
||||
this._types[type] = value;
|
||||
}
|
||||
|
||||
getData(type: string) {
|
||||
return this._types[type] || "";
|
||||
}
|
||||
}
|
||||
|
||||
export const testPolyfills = {
|
||||
ClipboardEvent,
|
||||
DataTransfer,
|
||||
DataTransferItem,
|
||||
};
|
||||
@@ -468,16 +468,16 @@ export class UI {
|
||||
static async editText<
|
||||
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
|
||||
>(element: T, text: string) {
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
const openedEditor =
|
||||
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
|
||||
const openedEditor = document.querySelector<HTMLTextAreaElement>(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
);
|
||||
|
||||
if (!openedEditor) {
|
||||
mouse.select(element);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
}
|
||||
|
||||
const editor = await getTextEditor(textEditorSelector);
|
||||
const editor = await getTextEditor();
|
||||
if (!editor) {
|
||||
throw new Error("Can't find wysiwyg text editor in the dom");
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ describe("Test Linear Elements", () => {
|
||||
|
||||
// drag line from midpoint
|
||||
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(line.points.length).toEqual(3);
|
||||
@@ -416,7 +416,7 @@ describe("Test Linear Elements", () => {
|
||||
lastSegmentMidpoint[1] + delta,
|
||||
]);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
|
||||
expect(line.points.length).toEqual(5);
|
||||
@@ -519,7 +519,7 @@ describe("Test Linear Elements", () => {
|
||||
// delete 3rd point
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(20);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
|
||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||
@@ -566,7 +566,7 @@ describe("Test Linear Elements", () => {
|
||||
lastSegmentMidpoint[0] + delta,
|
||||
lastSegmentMidpoint[1] + delta,
|
||||
]);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
|
||||
@@ -15,9 +15,7 @@ describe("event callbacks", () => {
|
||||
beforeEach(async () => {
|
||||
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
|
||||
await render(
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||
/>,
|
||||
<Excalidraw ref={(api) => excalidrawAPIPromise.resolve(api as any)} />,
|
||||
);
|
||||
excalidrawAPI = await excalidrawAPIPromise;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user